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