forked from
leaflet.pub/leaflet
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 } 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 {/*
88 the iframe!
89 can also add 'allow' and 'referrerpolicy' attributes later if needed
90 */}
91 <iframe
92 className={`
93 flex flex-col relative w-full overflow-hidden group/embedBlock
94 ${isSelected ? "block-border-selected " : "block-border"}
95 `}
96 width="100%"
97 height={height + (heightHandle.dragDelta?.y || 0)}
98 src={url?.data.value}
99 allow="fullscreen"
100 loading="lazy"
101 ></iframe>
102 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
103 <a
104 href={url?.data.value}
105 target="_blank"
106 className={`py-0.5 min-w-0 w-full whitespace-nowrap`}
107 >
108 {url?.data.value}
109 </a>
110 </div> */}
111
112 {!props.preview && permissions.write && (
113 <>
114 <div
115 data-draggable
116 className={`resizeHandle
117 cursor-ns-resize shrink-0 z-10 w-6 h-[5px]
118 absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px]
119 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]
120 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`}
121 {...heightHandle.handlers}
122 />
123 </>
124 )}
125 </div>
126 );
127};
128
129// TODO: maybe extract into a component…
130// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)
131const BlockLinkInput = (props: BlockProps) => {
132 let isSelected = useUIState((s) =>
133 s.selectedBlocks.find((b) => b.value === props.entityID),
134 );
135 let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value;
136
137 let entity_set = useEntitySetContext();
138 let [linkValue, setLinkValue] = useState("");
139 let [loading, setLoading] = useState(false);
140 let { rep } = useReplicache();
141 let submit = async () => {
142 let entity = props.entityID;
143 if (!entity) {
144 entity = v7();
145
146 await rep?.mutate.addBlock({
147 permission_set: entity_set.set,
148 factID: v7(),
149 parent: props.parent,
150 type: "card",
151 position: generateKeyBetween(props.position, props.nextPosition),
152 newEntityID: entity,
153 });
154 }
155 let link = linkValue;
156 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
157 if (!rep) return;
158
159 // Try to get embed URL from iframely, fallback to direct URL
160 setLoading(true);
161 try {
162 let res = await fetch("/api/link_previews", {
163 headers: { "Content-Type": "application/json" },
164 method: "POST",
165 body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
166 });
167
168 let embedUrl = link;
169 let embedHeight = 360;
170
171 if (res.status === 200) {
172 let data = await (res.json() as LinkPreviewMetadataResult);
173 if (data.success && data.data.links?.player?.[0]) {
174 let embed = data.data.links.player[0];
175 embedUrl = embed.href;
176 embedHeight = embed.media?.height || 300;
177 }
178 }
179
180 await rep.mutate.assertFact([
181 {
182 entity: entity,
183 attribute: "embed/url",
184 data: {
185 type: "string",
186 value: embedUrl,
187 },
188 },
189 {
190 entity: entity,
191 attribute: "embed/height",
192 data: {
193 type: "number",
194 value: embedHeight,
195 },
196 },
197 ]);
198 } catch {
199 // On any error, fallback to using the URL directly
200 await rep.mutate.assertFact([
201 {
202 entity: entity,
203 attribute: "embed/url",
204 data: {
205 type: "string",
206 value: link,
207 },
208 },
209 ]);
210 } finally {
211 setLoading(false);
212 }
213 };
214 let smoker = useSmoker();
215
216 return (
217 <form
218 onSubmit={(e) => {
219 e.preventDefault();
220 if (loading) return;
221 let rect = document
222 .getElementById("embed-block-submit")
223 ?.getBoundingClientRect();
224 if (!linkValue || linkValue === "") {
225 smoker({
226 error: true,
227 text: "no url!",
228 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 },
229 });
230 return;
231 }
232 if (!isUrl(linkValue)) {
233 smoker({
234 error: true,
235 text: "invalid url!",
236 position: {
237 x: rect ? rect.left + 12 : 0,
238 y: rect ? rect.top : 0,
239 },
240 });
241 return;
242 }
243 submit();
244 }}
245 >
246 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
247 <BlockEmbedSmall
248 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
249 />
250 <Separator />
251 <Input
252 type="text"
253 className="w-full grow border-none outline-hidden bg-transparent "
254 placeholder="www.example.com"
255 value={linkValue}
256 disabled={isLocked}
257 onChange={(e) => setLinkValue(e.target.value)}
258 />
259 <button
260 type="submit"
261 id="embed-block-submit"
262 disabled={loading}
263 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
264 onMouseDown={(e) => {
265 e.preventDefault();
266 if (loading) return;
267 if (!linkValue || linkValue === "") {
268 smoker({
269 error: true,
270 text: "no url!",
271 position: { x: e.clientX + 12, y: e.clientY },
272 });
273 return;
274 }
275 if (!isUrl(linkValue)) {
276 smoker({
277 error: true,
278 text: "invalid url!",
279 position: { x: e.clientX + 12, y: e.clientY },
280 });
281 return;
282 }
283 submit();
284 }}
285 >
286 {loading ? <DotLoader /> : <CheckTiny />}
287 </button>
288 </div>
289 </form>
290 );
291};