a tool for shared writing and social publishing
at main 8.8 kB view raw
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};