a tool for shared writing and social publishing
at main 7.8 kB view raw
1import { useEntitySetContext } from "components/EntitySetProvider"; 2import { generateKeyBetween } from "fractional-indexing"; 3import { useEffect, useState } from "react"; 4import { useEntity, useReplicache } from "src/replicache"; 5import { useUIState } from "src/useUIState"; 6import { addLinkBlock } from "src/utils/addLinkBlock"; 7import { BlockProps, BlockLayout } from "./Block"; 8import { v7 } from "uuid"; 9import { useSmoker } from "components/Toast"; 10import { Separator } from "components/Layout"; 11import { Input } from "components/Input"; 12import { focusElement } from "src/utils/focusElement"; 13import { isUrl } from "src/utils/isURL"; 14import { elementId } from "src/utils/elementId"; 15import { focusBlock } from "src/utils/focusBlock"; 16import { CheckTiny } from "components/Icons/CheckTiny"; 17import { LinkSmall } from "components/Icons/LinkSmall"; 18 19export const ExternalLinkBlock = ( 20 props: BlockProps & { preview?: boolean }, 21) => { 22 let { permissions } = useEntitySetContext(); 23 let previewImage = useEntity(props.entityID, "link/preview"); 24 let title = useEntity(props.entityID, "link/title"); 25 let description = useEntity(props.entityID, "link/description"); 26 let url = useEntity(props.entityID, "link/url"); 27 28 let isSelected = useUIState((s) => 29 s.selectedBlocks.find((b) => b.value === props.entityID), 30 ); 31 useEffect(() => { 32 if (props.preview) return; 33 let input = document.getElementById(elementId.block(props.entityID).input); 34 if (isSelected) { 35 setTimeout(() => { 36 let input = document.getElementById( 37 elementId.block(props.entityID).input, 38 ); 39 focusElement(input as HTMLInputElement | null); 40 }, 20); 41 } else input?.blur(); 42 }, [isSelected, props.entityID, props.preview]); 43 44 if (url === undefined) { 45 if (!permissions.write) return null; 46 return ( 47 <label 48 className={` 49 w-full h-[104px] p-2 50 text-tertiary hover:text-accent-contrast hover:cursor-pointer 51 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 52 ${isSelected ? "border-2 border-tertiary" : "border border-border"} 53 ${props.pageType === "canvas" && "bg-bg-page"}`} 54 onMouseDown={() => { 55 focusBlock( 56 { type: props.type, value: props.entityID, parent: props.parent }, 57 { type: "start" }, 58 ); 59 }} 60 > 61 <BlockLinkInput {...props} /> 62 </label> 63 ); 64 } 65 66 return ( 67 <BlockLayout 68 isSelected={!!isSelected} 69 hasBackground="page" 70 borderOnHover 71 className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!" 72 > 73 <a 74 href={url?.data.value} 75 target="_blank" 76 className="flex w-full h-full text-primary hover:no-underline no-underline" 77 > 78 <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 <div className="flex flex-col w-full min-w-0 h-full grow "> 80 <div 81 className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 style={{ 83 overflow: "hidden", 84 textOverflow: "ellipsis", 85 wordBreak: "break-all", 86 }} 87 > 88 {title?.data.value} 89 </div> 90 91 <div 92 className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 93 > 94 {description?.data.value} 95 </div> 96 <div 97 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 98 className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 99 > 100 {url?.data.value} 101 </div> 102 </div> 103 </div> 104 105 <div 106 className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 107 style={{ 108 backgroundImage: `url(${previewImage?.data.src})`, 109 backgroundPosition: "center", 110 }} 111 /> 112 </a> 113 </BlockLayout> 114 ); 115}; 116 117const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => { 118 let isSelected = useUIState((s) => 119 s.selectedBlocks.find((b) => b.value === props.entityID), 120 ); 121 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 122 let entity_set = useEntitySetContext(); 123 let [linkValue, setLinkValue] = useState(""); 124 let { rep } = useReplicache(); 125 let submit = async () => { 126 let linkEntity = props.entityID; 127 if (!linkEntity) { 128 linkEntity = v7(); 129 130 await rep?.mutate.addBlock({ 131 permission_set: entity_set.set, 132 factID: v7(), 133 parent: props.parent, 134 type: "card", 135 position: generateKeyBetween(props.position, props.nextPosition), 136 newEntityID: linkEntity, 137 }); 138 } 139 let link = linkValue; 140 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 141 addLinkBlock(link, linkEntity, rep); 142 143 let textEntity = v7(); 144 await rep?.mutate.addBlock({ 145 permission_set: entity_set.set, 146 factID: v7(), 147 parent: props.parent, 148 type: "text", 149 position: generateKeyBetween(props.position, props.nextPosition), 150 newEntityID: textEntity, 151 }); 152 153 focusBlock( 154 { 155 value: textEntity, 156 type: "text", 157 parent: props.parent, 158 }, 159 { type: "start" }, 160 ); 161 }; 162 let smoker = useSmoker(); 163 164 return ( 165 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 166 <> 167 <LinkSmall 168 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 169 /> 170 <Separator /> 171 <Input 172 id={ 173 !props.preview ? elementId.block(props.entityID).input : undefined 174 } 175 type="url" 176 disabled={isLocked} 177 className="w-full grow border-none outline-hidden bg-transparent " 178 placeholder="www.example.com" 179 value={linkValue} 180 onChange={(e) => setLinkValue(e.target.value)} 181 onKeyDown={(e) => { 182 if (e.key === "Enter") { 183 e.preventDefault(); 184 if (!linkValue) return; 185 if (!isUrl(linkValue)) { 186 let rect = e.currentTarget.getBoundingClientRect(); 187 smoker({ 188 alignOnMobile: "left", 189 error: true, 190 text: "invalid url!", 191 position: { x: rect.left, y: rect.top - 8 }, 192 }); 193 return; 194 } 195 submit(); 196 } 197 }} 198 /> 199 <div className="flex items-center gap-3 "> 200 <button 201 autoFocus={false} 202 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 203 onMouseDown={(e) => { 204 e.preventDefault(); 205 if (!linkValue || linkValue === "") { 206 smoker({ 207 alignOnMobile: "left", 208 error: true, 209 text: "no url!", 210 position: { x: e.clientX, y: e.clientY }, 211 }); 212 return; 213 } 214 if (!isUrl(linkValue)) { 215 smoker({ 216 alignOnMobile: "left", 217 error: true, 218 text: "invalid url!", 219 position: { x: e.clientX, y: e.clientY }, 220 }); 221 return; 222 } 223 submit(); 224 }} 225 > 226 <CheckTiny /> 227 </button> 228 </div> 229 </> 230 </div> 231 ); 232};