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