a tool for shared writing and social publishing
at refactor/pub-settings 396 lines 12 kB view raw
1import { useEntitySetContext } from "components/EntitySetProvider"; 2import { generateKeyBetween } from "fractional-indexing"; 3import { useCallback, useEffect, useMemo, 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"; 22import { getAspectRatio } from "src/utils/aspectRatio"; 23import { useIframeChannel } from "src/hooks/useIframeChannel"; 24import { scrollIntoView } from "src/utils/scrollIntoView"; 25import { EmbedBlockData } from "src/partsPageChannel"; 26import { useColorAttribute } from "components/ThemeManager/useColorAttribute"; 27 28export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { 29 let entity_set = useEntitySetContext(); 30 let { permissions } = entity_set; 31 let { rep } = useReplicache(); 32 let url = useEntity(props.entityID, "embed/url"); 33 let isCanvasBlock = props.pageType === "canvas"; 34 35 let isSelected = useUIState((s) => 36 s.selectedBlocks.find((b) => b.value === props.entityID), 37 ); 38 39 let height = useEntity(props.entityID, "embed/height")?.data.value || 360; 40 let aspectRatio = useEntity(props.entityID, "embed/aspect-ratio")?.data.value; 41 42 let heightOnDragEnd = useCallback( 43 (dragPosition: { x: number; y: number }) => { 44 rep?.mutate.assertFact({ 45 entity: props.entityID, 46 attribute: "embed/height", 47 data: { 48 type: "number", 49 value: height + dragPosition.y, 50 }, 51 }); 52 }, 53 [props, rep, height], 54 ); 55 56 let heightHandle = useDrag({ onDragEnd: heightOnDragEnd }); 57 58 let assertBlockData = useCallback( 59 async (entityID: string, block: EmbedBlockData) => { 60 if (!rep) return; 61 if (block.type === "text") { 62 await rep.mutate.assertFact([ 63 { 64 entity: entityID, 65 attribute: "block/type", 66 data: { type: "block-type-union", value: "text" }, 67 }, 68 { 69 entity: entityID, 70 attribute: "block/text", 71 data: { type: "text", value: block.content }, 72 }, 73 ]); 74 } else { 75 let facts: Parameters<typeof rep.mutate.assertFact>[0] = [ 76 { 77 entity: entityID, 78 attribute: "block/type", 79 data: { type: "block-type-union", value: "embed" }, 80 }, 81 { 82 entity: entityID, 83 attribute: "embed/url", 84 data: { type: "string", value: block.url }, 85 }, 86 ]; 87 if (block.aspectRatio) { 88 facts.push({ 89 entity: entityID, 90 attribute: "embed/aspect-ratio", 91 data: { type: "string", value: block.aspectRatio }, 92 }); 93 } else if (block.height) { 94 facts.push({ 95 entity: entityID, 96 attribute: "embed/height", 97 data: { type: "number", value: block.height }, 98 }); 99 } 100 await rep.mutate.assertFact(facts); 101 } 102 }, 103 [rep], 104 ); 105 106 let { iframeRef } = useIframeChannel({ 107 onOpen: (openUrl) => { 108 useUIState 109 .getState() 110 .openPage(props.parent, { type: "iframe", url: openUrl }); 111 scrollIntoView(`iframe-page-${openUrl}`, "pages", 0.8); 112 }, 113 onReplaceWith: (block) => { 114 assertBlockData(props.entityID, block); 115 }, 116 onAddBelow: async (block) => { 117 if (!rep) return; 118 let newEntityID = v7(); 119 await rep.mutate.addBlock({ 120 permission_set: entity_set.set, 121 factID: v7(), 122 parent: props.parent, 123 type: block.type === "text" ? "text" : "card", 124 position: generateKeyBetween(props.position, props.nextPosition), 125 newEntityID, 126 }); 127 await assertBlockData(newEntityID, block); 128 }, 129 }); 130 131 useEffect(() => { 132 if (props.preview) return; 133 let input = document.getElementById(elementId.block(props.entityID).input); 134 if (isSelected) { 135 input?.focus(); 136 } else input?.blur(); 137 }, [isSelected, props.entityID, props.preview]); 138 139 let bgPage = useColorAttribute(null, "theme/page-background"); 140 let primary = useColorAttribute(null, "theme/primary"); 141 let iframeSrc = useMemo(() => { 142 if (!url) return undefined; 143 let src = new URL(url.data.value); 144 src.searchParams.set("parts.page.embed.ctx.mode", "edit"); 145 src.searchParams.set( 146 "parts.page.embed.ctx.bgColor", 147 bgPage.toString("hex"), 148 ); 149 src.searchParams.set( 150 "parts.page.embed.ctx.primaryColor", 151 primary.toString("hex"), 152 ); 153 return src.toString(); 154 }, [url, bgPage, primary]); 155 156 if (props.preview) return null; 157 if (!url) { 158 if (!permissions.write) return null; 159 return ( 160 <label 161 id={props.preview ? undefined : elementId.block(props.entityID).input} 162 className={` 163 w-full h-[420px] p-2 164 text-tertiary hover:text-accent-contrast hover:cursor-pointer 165 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 166 ${isSelected ? "border-2 border-tertiary" : "border border-border"} 167 ${props.pageType === "canvas" && "bg-bg-page"}`} 168 onMouseDown={() => { 169 focusBlock( 170 { type: props.type, value: props.entityID, parent: props.parent }, 171 { type: "start" }, 172 ); 173 }} 174 > 175 <BlockLinkInput {...props} /> 176 </label> 177 ); 178 } 179 180 return ( 181 <div 182 className={`w-full ${!aspectRatio && heightHandle.dragDelta ? "pointer-events-none" : ""}`} 183 > 184 <BlockLayout 185 isSelected={!!isSelected} 186 className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" 187 > 188 <iframe 189 ref={iframeRef} 190 className={aspectRatio ? "w-full h-auto" : "w-full"} 191 style={ 192 aspectRatio 193 ? { aspectRatio } 194 : { height: height + (heightHandle.dragDelta?.y || 0) } 195 } 196 src={iframeSrc} 197 allow="fullscreen" 198 loading="lazy" 199 referrerPolicy="no-referrer" 200 ></iframe> 201 </BlockLayout> 202 203 {!props.preview && permissions.write && !aspectRatio && ( 204 <> 205 <div 206 data-draggable 207 className={`resizeHandle 208 209 210 cursor-ns-resize shrink-0 z-10 w-6 h-[5px] 211 absolute bottom-[3px] right-1/2 translate-x-1/2 212 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] 213 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} 214 {...heightHandle.handlers} 215 /> 216 </> 217 )} 218 </div> 219 ); 220}; 221 222// TODO: maybe extract into a component… 223// would just have to branch for the mutations (addLinkBlock or addEmbedBlock) 224const BlockLinkInput = (props: BlockProps) => { 225 let isSelected = useUIState((s) => 226 s.selectedBlocks.find((b) => b.value === props.entityID), 227 ); 228 229 let entity_set = useEntitySetContext(); 230 let [linkValue, setLinkValue] = useState(""); 231 let [loading, setLoading] = useState(false); 232 let { rep } = useReplicache(); 233 let submit = async () => { 234 let entity = props.entityID; 235 if (!entity) { 236 entity = v7(); 237 238 await rep?.mutate.addBlock({ 239 permission_set: entity_set.set, 240 factID: v7(), 241 parent: props.parent, 242 type: "card", 243 position: generateKeyBetween(props.position, props.nextPosition), 244 newEntityID: entity, 245 }); 246 } 247 let link = linkValue; 248 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 249 if (!rep) return; 250 251 // Try to get embed URL from iframely, fallback to direct URL 252 setLoading(true); 253 try { 254 let res = await fetch("/api/link_previews", { 255 headers: { "Content-Type": "application/json" }, 256 method: "POST", 257 body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody), 258 }); 259 260 let embedUrl = link; 261 let embedHeight = 360; 262 let embedAspectRatio: string | null = null; 263 264 if (res.status === 200) { 265 let data = await (res.json() as LinkPreviewMetadataResult); 266 if (data.success && data.data.links?.player?.[0]) { 267 let embed = data.data.links.player[0]; 268 embedUrl = embed.href; 269 embedHeight = embed.media?.height || 300; 270 embedAspectRatio = getAspectRatio(embed.media); 271 } 272 } 273 274 let facts: Parameters<typeof rep.mutate.assertFact>[0] = [ 275 { 276 entity: entity, 277 attribute: "embed/url", 278 data: { 279 type: "string", 280 value: embedUrl, 281 }, 282 }, 283 ]; 284 if (embedAspectRatio) { 285 facts.push({ 286 entity: entity, 287 attribute: "embed/aspect-ratio", 288 data: { 289 type: "string", 290 value: embedAspectRatio, 291 }, 292 }); 293 } else { 294 facts.push({ 295 entity: entity, 296 attribute: "embed/height", 297 data: { 298 type: "number", 299 value: embedHeight, 300 }, 301 }); 302 } 303 await rep.mutate.assertFact(facts); 304 } catch { 305 // On any error, fallback to using the URL directly 306 await rep.mutate.assertFact([ 307 { 308 entity: entity, 309 attribute: "embed/url", 310 data: { 311 type: "string", 312 value: link, 313 }, 314 }, 315 ]); 316 } finally { 317 setLoading(false); 318 } 319 }; 320 let smoker = useSmoker(); 321 322 return ( 323 <form 324 onSubmit={(e) => { 325 e.preventDefault(); 326 if (loading) return; 327 let rect = document 328 .getElementById("embed-block-submit") 329 ?.getBoundingClientRect(); 330 if (!linkValue || linkValue === "") { 331 smoker({ 332 error: true, 333 text: "no url!", 334 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 }, 335 }); 336 return; 337 } 338 if (!isUrl(linkValue)) { 339 smoker({ 340 error: true, 341 text: "invalid url!", 342 position: { 343 x: rect ? rect.left + 12 : 0, 344 y: rect ? rect.top : 0, 345 }, 346 }); 347 return; 348 } 349 submit(); 350 }} 351 > 352 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 353 <BlockEmbedSmall 354 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 355 /> 356 <Separator /> 357 <Input 358 type="text" 359 className="w-full grow border-none outline-hidden bg-transparent " 360 placeholder="www.example.com" 361 value={linkValue} 362 onChange={(e) => setLinkValue(e.target.value)} 363 /> 364 <button 365 type="submit" 366 id="embed-block-submit" 367 disabled={loading} 368 className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 369 onMouseDown={(e) => { 370 e.preventDefault(); 371 if (loading) return; 372 if (!linkValue || linkValue === "") { 373 smoker({ 374 error: true, 375 text: "no url!", 376 position: { x: e.clientX + 12, y: e.clientY }, 377 }); 378 return; 379 } 380 if (!isUrl(linkValue)) { 381 smoker({ 382 error: true, 383 text: "invalid url!", 384 position: { x: e.clientX + 12, y: e.clientY }, 385 }); 386 return; 387 } 388 submit(); 389 }} 390 > 391 {loading ? <DotLoader /> : <CheckTiny />} 392 </button> 393 </div> 394 </form> 395 ); 396};