a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useCallback, useEffect, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { BlockProps, BlockLayout } from "./Block";
7import { v7 } from "uuid";
8import { useSmoker } from "components/Toast";
9import { Separator } from "components/Layout";
10import { Input } from "components/Input";
11import { isUrl } from "src/utils/isURL";
12import { elementId } from "src/utils/elementId";
13import { focusBlock } from "src/utils/focusBlock";
14import { useDrag } from "src/hooks/useDrag";
15import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
16import { CheckTiny } from "components/Icons/CheckTiny";
17import { DotLoader } from "components/utils/DotLoader";
18import {
19 LinkPreviewBody,
20 LinkPreviewMetadataResult,
21} from "app/api/link_previews/route";
22
23export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => {
24 let { permissions } = useEntitySetContext();
25 let { rep } = useReplicache();
26 let url = useEntity(props.entityID, "embed/url");
27 let isCanvasBlock = props.pageType === "canvas";
28
29 let isSelected = useUIState((s) =>
30 s.selectedBlocks.find((b) => b.value === props.entityID),
31 );
32
33 let height = useEntity(props.entityID, "embed/height")?.data.value || 360;
34
35 let heightOnDragEnd = useCallback(
36 (dragPosition: { x: number; y: number }) => {
37 rep?.mutate.assertFact({
38 entity: props.entityID,
39 attribute: "embed/height",
40 data: {
41 type: "number",
42 value: height + dragPosition.y,
43 },
44 });
45 },
46 [props, rep, height],
47 );
48
49 let heightHandle = useDrag({ onDragEnd: heightOnDragEnd });
50
51 useEffect(() => {
52 if (props.preview) return;
53 let input = document.getElementById(elementId.block(props.entityID).input);
54 if (isSelected) {
55 input?.focus();
56 } else input?.blur();
57 }, [isSelected, props.entityID, props.preview]);
58
59 if (!url) {
60 if (!permissions.write) return null;
61 return (
62 <label
63 id={props.preview ? undefined : elementId.block(props.entityID).input}
64 className={`
65 w-full h-[420px] p-2
66 text-tertiary hover:text-accent-contrast hover:cursor-pointer
67 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
68 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
69 ${props.pageType === "canvas" && "bg-bg-page"}`}
70 onMouseDown={() => {
71 focusBlock(
72 { type: props.type, value: props.entityID, parent: props.parent },
73 { type: "start" },
74 );
75 }}
76 >
77 <BlockLinkInput {...props} />
78 </label>
79 );
80 }
81 if (props.preview) return null;
82
83 return (
84 <div
85 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
86 >
87 <BlockLayout
88 isSelected={!!isSelected}
89 className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!"
90 >
91 <iframe
92 width="100%"
93 height={height + (heightHandle.dragDelta?.y || 0)}
94 src={url?.data.value}
95 allow="fullscreen"
96 loading="lazy"
97 ></iframe>
98 </BlockLayout>
99 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
100 <a
101 href={url?.data.value}
102 target="_blank"
103 className={`py-0.5 min-w-0 w-full whitespace-nowrap`}
104 >
105 {url?.data.value}
106 </a>
107 </div> */}
108
109 {!props.preview && permissions.write && (
110 <>
111 <div
112 data-draggable
113 className={`resizeHandle
114 cursor-ns-resize shrink-0 z-10 w-6 h-[5px]
115 absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px]
116 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]
117 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`}
118 {...heightHandle.handlers}
119 />
120 </>
121 )}
122 </div>
123 );
124};
125
126// TODO: maybe extract into a component…
127// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)
128const BlockLinkInput = (props: BlockProps) => {
129 let isSelected = useUIState((s) =>
130 s.selectedBlocks.find((b) => b.value === props.entityID),
131 );
132 let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value;
133
134 let entity_set = useEntitySetContext();
135 let [linkValue, setLinkValue] = useState("");
136 let [loading, setLoading] = useState(false);
137 let { rep } = useReplicache();
138 let submit = async () => {
139 let entity = props.entityID;
140 if (!entity) {
141 entity = v7();
142
143 await rep?.mutate.addBlock({
144 permission_set: entity_set.set,
145 factID: v7(),
146 parent: props.parent,
147 type: "card",
148 position: generateKeyBetween(props.position, props.nextPosition),
149 newEntityID: entity,
150 });
151 }
152 let link = linkValue;
153 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
154 if (!rep) return;
155
156 // Try to get embed URL from iframely, fallback to direct URL
157 setLoading(true);
158 try {
159 let res = await fetch("/api/link_previews", {
160 headers: { "Content-Type": "application/json" },
161 method: "POST",
162 body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
163 });
164
165 let embedUrl = link;
166 let embedHeight = 360;
167
168 if (res.status === 200) {
169 let data = await (res.json() as LinkPreviewMetadataResult);
170 if (data.success && data.data.links?.player?.[0]) {
171 let embed = data.data.links.player[0];
172 embedUrl = embed.href;
173 embedHeight = embed.media?.height || 300;
174 }
175 }
176
177 await rep.mutate.assertFact([
178 {
179 entity: entity,
180 attribute: "embed/url",
181 data: {
182 type: "string",
183 value: embedUrl,
184 },
185 },
186 {
187 entity: entity,
188 attribute: "embed/height",
189 data: {
190 type: "number",
191 value: embedHeight,
192 },
193 },
194 ]);
195 } catch {
196 // On any error, fallback to using the URL directly
197 await rep.mutate.assertFact([
198 {
199 entity: entity,
200 attribute: "embed/url",
201 data: {
202 type: "string",
203 value: link,
204 },
205 },
206 ]);
207 } finally {
208 setLoading(false);
209 }
210 };
211 let smoker = useSmoker();
212
213 return (
214 <form
215 onSubmit={(e) => {
216 e.preventDefault();
217 if (loading) return;
218 let rect = document
219 .getElementById("embed-block-submit")
220 ?.getBoundingClientRect();
221 if (!linkValue || linkValue === "") {
222 smoker({
223 error: true,
224 text: "no url!",
225 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 },
226 });
227 return;
228 }
229 if (!isUrl(linkValue)) {
230 smoker({
231 error: true,
232 text: "invalid url!",
233 position: {
234 x: rect ? rect.left + 12 : 0,
235 y: rect ? rect.top : 0,
236 },
237 });
238 return;
239 }
240 submit();
241 }}
242 >
243 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
244 <BlockEmbedSmall
245 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
246 />
247 <Separator />
248 <Input
249 type="text"
250 className="w-full grow border-none outline-hidden bg-transparent "
251 placeholder="www.example.com"
252 value={linkValue}
253 disabled={isLocked}
254 onChange={(e) => setLinkValue(e.target.value)}
255 />
256 <button
257 type="submit"
258 id="embed-block-submit"
259 disabled={loading}
260 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
261 onMouseDown={(e) => {
262 e.preventDefault();
263 if (loading) return;
264 if (!linkValue || linkValue === "") {
265 smoker({
266 error: true,
267 text: "no url!",
268 position: { x: e.clientX + 12, y: e.clientY },
269 });
270 return;
271 }
272 if (!isUrl(linkValue)) {
273 smoker({
274 error: true,
275 text: "invalid url!",
276 position: { x: e.clientX + 12, y: e.clientY },
277 });
278 return;
279 }
280 submit();
281 }}
282 >
283 {loading ? <DotLoader /> : <CheckTiny />}
284 </button>
285 </div>
286 </form>
287 );
288};