a tool for shared writing and social publishing
at feature/recommend 230 lines 7.7 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 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 className="w-full grow border-none outline-hidden bg-transparent " 176 placeholder="www.example.com" 177 value={linkValue} 178 onChange={(e) => setLinkValue(e.target.value)} 179 onKeyDown={(e) => { 180 if (e.key === "Enter") { 181 e.preventDefault(); 182 if (!linkValue) return; 183 if (!isUrl(linkValue)) { 184 let rect = e.currentTarget.getBoundingClientRect(); 185 smoker({ 186 alignOnMobile: "left", 187 error: true, 188 text: "invalid url!", 189 position: { x: rect.left, y: rect.top - 8 }, 190 }); 191 return; 192 } 193 submit(); 194 } 195 }} 196 /> 197 <div className="flex items-center gap-3 "> 198 <button 199 autoFocus={false} 200 className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} 201 onMouseDown={(e) => { 202 e.preventDefault(); 203 if (!linkValue || linkValue === "") { 204 smoker({ 205 alignOnMobile: "left", 206 error: true, 207 text: "no url!", 208 position: { x: e.clientX, y: e.clientY }, 209 }); 210 return; 211 } 212 if (!isUrl(linkValue)) { 213 smoker({ 214 alignOnMobile: "left", 215 error: true, 216 text: "invalid url!", 217 position: { x: e.clientX, y: e.clientY }, 218 }); 219 return; 220 } 221 submit(); 222 }} 223 > 224 <CheckTiny /> 225 </button> 226 </div> 227 </> 228 </div> 229 ); 230};