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
115
116 cursor-ns-resize shrink-0 z-10 w-6 h-[5px]
117 absolute bottom-[3px] right-1/2 translate-x-1/2
118 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]
119 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`}
120 {...heightHandle.handlers}
121 />
122 </>
123 )}
124 </div>
125 );
126};
127
128// TODO: maybe extract into a component…
129// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)
130const BlockLinkInput = (props: BlockProps) => {
131 let isSelected = useUIState((s) =>
132 s.selectedBlocks.find((b) => b.value === props.entityID),
133 );
134
135 let entity_set = useEntitySetContext();
136 let [linkValue, setLinkValue] = useState("");
137 let [loading, setLoading] = useState(false);
138 let { rep } = useReplicache();
139 let submit = async () => {
140 let entity = props.entityID;
141 if (!entity) {
142 entity = v7();
143
144 await rep?.mutate.addBlock({
145 permission_set: entity_set.set,
146 factID: v7(),
147 parent: props.parent,
148 type: "card",
149 position: generateKeyBetween(props.position, props.nextPosition),
150 newEntityID: entity,
151 });
152 }
153 let link = linkValue;
154 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
155 if (!rep) return;
156
157 // Try to get embed URL from iframely, fallback to direct URL
158 setLoading(true);
159 try {
160 let res = await fetch("/api/link_previews", {
161 headers: { "Content-Type": "application/json" },
162 method: "POST",
163 body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
164 });
165
166 let embedUrl = link;
167 let embedHeight = 360;
168
169 if (res.status === 200) {
170 let data = await (res.json() as LinkPreviewMetadataResult);
171 if (data.success && data.data.links?.player?.[0]) {
172 let embed = data.data.links.player[0];
173 embedUrl = embed.href;
174 embedHeight = embed.media?.height || 300;
175 }
176 }
177
178 await rep.mutate.assertFact([
179 {
180 entity: entity,
181 attribute: "embed/url",
182 data: {
183 type: "string",
184 value: embedUrl,
185 },
186 },
187 {
188 entity: entity,
189 attribute: "embed/height",
190 data: {
191 type: "number",
192 value: embedHeight,
193 },
194 },
195 ]);
196 } catch {
197 // On any error, fallback to using the URL directly
198 await rep.mutate.assertFact([
199 {
200 entity: entity,
201 attribute: "embed/url",
202 data: {
203 type: "string",
204 value: link,
205 },
206 },
207 ]);
208 } finally {
209 setLoading(false);
210 }
211 };
212 let smoker = useSmoker();
213
214 return (
215 <form
216 onSubmit={(e) => {
217 e.preventDefault();
218 if (loading) return;
219 let rect = document
220 .getElementById("embed-block-submit")
221 ?.getBoundingClientRect();
222 if (!linkValue || linkValue === "") {
223 smoker({
224 error: true,
225 text: "no url!",
226 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 },
227 });
228 return;
229 }
230 if (!isUrl(linkValue)) {
231 smoker({
232 error: true,
233 text: "invalid url!",
234 position: {
235 x: rect ? rect.left + 12 : 0,
236 y: rect ? rect.top : 0,
237 },
238 });
239 return;
240 }
241 submit();
242 }}
243 >
244 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
245 <BlockEmbedSmall
246 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
247 />
248 <Separator />
249 <Input
250 type="text"
251 className="w-full grow border-none outline-hidden bg-transparent "
252 placeholder="www.example.com"
253 value={linkValue}
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 ? "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};