a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useEffect, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { addLinkBlock } from "src/utils/addLinkBlock";
7import { BlockProps } from "./Block";
8import { v7 } from "uuid";
9import { useSmoker } from "components/Toast";
10import { Separator } from "components/Layout";
11import { focusElement, Input } from "components/Input";
12import { isUrl } from "src/utils/isURL";
13import { elementId } from "src/utils/elementId";
14import { focusBlock } from "src/utils/focusBlock";
15import { CheckTiny } from "components/Icons/CheckTiny";
16import { LinkSmall } from "components/Icons/LinkSmall";
17
18export const ExternalLinkBlock = (
19 props: BlockProps & { preview?: boolean },
20) => {
21 let { permissions } = useEntitySetContext();
22 let previewImage = useEntity(props.entityID, "link/preview");
23 let title = useEntity(props.entityID, "link/title");
24 let description = useEntity(props.entityID, "link/description");
25 let url = useEntity(props.entityID, "link/url");
26
27 let isSelected = useUIState((s) =>
28 s.selectedBlocks.find((b) => b.value === props.entityID),
29 );
30 useEffect(() => {
31 if (props.preview) return;
32 let input = document.getElementById(elementId.block(props.entityID).input);
33 if (isSelected) {
34 setTimeout(() => {
35 let input = document.getElementById(
36 elementId.block(props.entityID).input,
37 );
38 focusElement(input as HTMLInputElement | null);
39 }, 20);
40 } else input?.blur();
41 }, [isSelected, props.entityID, props.preview]);
42
43 if (url === undefined) {
44 if (!permissions.write) return null;
45 return (
46 <label
47 className={`
48 w-full h-[104px] p-2
49 text-tertiary hover:text-accent-contrast hover:cursor-pointer
50 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
51 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
52 ${props.pageType === "canvas" && "bg-bg-page"}`}
53 onMouseDown={() => {
54 focusBlock(
55 { type: props.type, value: props.entityID, parent: props.parent },
56 { type: "start" },
57 );
58 }}
59 >
60 <BlockLinkInput {...props} />
61 </label>
62 );
63 }
64
65 return (
66 <a
67 href={url?.data.value}
68 target="_blank"
69 className={`
70 externalLinkBlock flex relative group/linkBlock
71 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline
72 hover:border-accent-contrast shadow-sm
73 ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"}
74
75 `}
76 >
77 <div className="pt-2 pb-2 px-3 grow min-w-0">
78 <div className="flex flex-col w-full min-w-0 h-full grow ">
79 <div
80 className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`}
81 style={{
82 overflow: "hidden",
83 textOverflow: "ellipsis",
84 wordBreak: "break-all",
85 }}
86 >
87 {title?.data.value}
88 </div>
89
90 <div
91 className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`}
92 >
93 {description?.data.value}
94 </div>
95 <div
96 style={{ wordBreak: "break-word" }} // better than tailwind break-all!
97 className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`}
98 >
99 {url?.data.value}
100 </div>
101 </div>
102 </div>
103
104 <div
105 className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`}
106 style={{
107 backgroundImage: `url(${previewImage?.data.src})`,
108 backgroundPosition: "center",
109 }}
110 />
111 </a>
112 );
113};
114
115const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => {
116 let isSelected = useUIState((s) =>
117 s.selectedBlocks.find((b) => b.value === props.entityID),
118 );
119 let isLocked = useEntity(props.value, "block/is-locked")?.data.value;
120 let entity_set = useEntitySetContext();
121 let [linkValue, setLinkValue] = useState("");
122 let { rep } = useReplicache();
123 let submit = async () => {
124 let linkEntity = props.entityID;
125 if (!linkEntity) {
126 linkEntity = v7();
127
128 await rep?.mutate.addBlock({
129 permission_set: entity_set.set,
130 factID: v7(),
131 parent: props.parent,
132 type: "card",
133 position: generateKeyBetween(props.position, props.nextPosition),
134 newEntityID: linkEntity,
135 });
136 }
137 let link = linkValue;
138 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
139 addLinkBlock(link, linkEntity, rep);
140
141 let textEntity = v7();
142 await rep?.mutate.addBlock({
143 permission_set: entity_set.set,
144 factID: v7(),
145 parent: props.parent,
146 type: "text",
147 position: generateKeyBetween(props.position, props.nextPosition),
148 newEntityID: textEntity,
149 });
150
151 focusBlock(
152 {
153 value: textEntity,
154 type: "text",
155 parent: props.parent,
156 },
157 { type: "start" },
158 );
159 };
160 let smoker = useSmoker();
161
162 return (
163 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
164 <>
165 <LinkSmall
166 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
167 />
168 <Separator />
169 <Input
170 id={
171 !props.preview ? elementId.block(props.entityID).input : undefined
172 }
173 type="url"
174 disabled={isLocked}
175 className="w-full grow border-none outline-hidden bg-transparent "
176 placeholder="www.example.com"
177 value={linkValue}
178 onChange={(e) => setLinkValue(e.target.value)}
179 onKeyDown={(e) => {
180 if (e.key === "Enter") {
181 e.preventDefault();
182 if (!linkValue) return;
183 if (!isUrl(linkValue)) {
184 let rect = e.currentTarget.getBoundingClientRect();
185 smoker({
186 alignOnMobile: "left",
187 error: true,
188 text: "invalid url!",
189 position: { x: rect.left, y: rect.top - 8 },
190 });
191 return;
192 }
193 submit();
194 }
195 }}
196 />
197 <div className="flex items-center gap-3 ">
198 <button
199 autoFocus={false}
200 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
201 onMouseDown={(e) => {
202 e.preventDefault();
203 if (!linkValue || linkValue === "") {
204 smoker({
205 alignOnMobile: "left",
206 error: true,
207 text: "no url!",
208 position: { x: e.clientX, y: e.clientY },
209 });
210 return;
211 }
212 if (!isUrl(linkValue)) {
213 smoker({
214 alignOnMobile: "left",
215 error: true,
216 text: "invalid url!",
217 position: { x: e.clientX, y: e.clientY },
218 });
219 return;
220 }
221 submit();
222 }}
223 >
224 <CheckTiny />
225 </button>
226 </div>
227 </>
228 </div>
229 );
230};