a tool for shared writing and social publishing
at main 231 lines 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 } 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 <a 68 href={url?.data.value} 69 target="_blank" 70 className={` 71 externalLinkBlock flex relative group/linkBlock 72 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 73 hover:border-accent-contrast shadow-sm 74 ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"} 75 76 `} 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 ); 114}; 115 116const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => { 117 let isSelected = useUIState((s) => 118 s.selectedBlocks.find((b) => b.value === props.entityID), 119 ); 120 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 121 let entity_set = useEntitySetContext(); 122 let [linkValue, setLinkValue] = useState(""); 123 let { rep } = useReplicache(); 124 let submit = async () => { 125 let linkEntity = props.entityID; 126 if (!linkEntity) { 127 linkEntity = v7(); 128 129 await rep?.mutate.addBlock({ 130 permission_set: entity_set.set, 131 factID: v7(), 132 parent: props.parent, 133 type: "card", 134 position: generateKeyBetween(props.position, props.nextPosition), 135 newEntityID: linkEntity, 136 }); 137 } 138 let link = linkValue; 139 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 140 addLinkBlock(link, linkEntity, rep); 141 142 let textEntity = v7(); 143 await rep?.mutate.addBlock({ 144 permission_set: entity_set.set, 145 factID: v7(), 146 parent: props.parent, 147 type: "text", 148 position: generateKeyBetween(props.position, props.nextPosition), 149 newEntityID: textEntity, 150 }); 151 152 focusBlock( 153 { 154 value: textEntity, 155 type: "text", 156 parent: props.parent, 157 }, 158 { type: "start" }, 159 ); 160 }; 161 let smoker = useSmoker(); 162 163 return ( 164 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 165 <> 166 <LinkSmall 167 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 168 /> 169 <Separator /> 170 <Input 171 id={ 172 !props.preview ? elementId.block(props.entityID).input : undefined 173 } 174 type="url" 175 disabled={isLocked} 176 className="w-full grow border-none outline-hidden bg-transparent " 177 placeholder="www.example.com" 178 value={linkValue} 179 onChange={(e) => setLinkValue(e.target.value)} 180 onKeyDown={(e) => { 181 if (e.key === "Enter") { 182 e.preventDefault(); 183 if (!linkValue) return; 184 if (!isUrl(linkValue)) { 185 let rect = e.currentTarget.getBoundingClientRect(); 186 smoker({ 187 alignOnMobile: "left", 188 error: true, 189 text: "invalid url!", 190 position: { x: rect.left, y: rect.top - 8 }, 191 }); 192 return; 193 } 194 submit(); 195 } 196 }} 197 /> 198 <div className="flex items-center gap-3 "> 199 <button 200 autoFocus={false} 201 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 202 onMouseDown={(e) => { 203 e.preventDefault(); 204 if (!linkValue || linkValue === "") { 205 smoker({ 206 alignOnMobile: "left", 207 error: true, 208 text: "no url!", 209 position: { x: e.clientX, y: e.clientY }, 210 }); 211 return; 212 } 213 if (!isUrl(linkValue)) { 214 smoker({ 215 alignOnMobile: "left", 216 error: true, 217 text: "invalid url!", 218 position: { x: e.clientX, y: e.clientY }, 219 }); 220 return; 221 } 222 submit(); 223 }} 224 > 225 <CheckTiny /> 226 </button> 227 </div> 228 </> 229 </div> 230 ); 231};