a tool for shared writing and social publishing
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 { focusElement, Input } from "components/Input"; 12import { isUrl } from "src/utils/isURL"; 13import { elementId } from "src/utils/elementId"; 14import { focusBlock } from "src/utils/focusBlock"; 15import { CheckTiny } from "components/Icons/CheckTiny"; 16import { LinkSmall } from "components/Icons/LinkSmall"; 17 18export const ExternalLinkBlock = ( 19 props: BlockProps & { preview?: boolean }, 20) => { 21 let { permissions } = useEntitySetContext(); 22 let previewImage = useEntity(props.entityID, "link/preview"); 23 let title = useEntity(props.entityID, "link/title"); 24 let description = useEntity(props.entityID, "link/description"); 25 let url = useEntity(props.entityID, "link/url"); 26 27 let isSelected = useUIState((s) => 28 s.selectedBlocks.find((b) => b.value === props.entityID), 29 ); 30 useEffect(() => { 31 if (props.preview) return; 32 let input = document.getElementById(elementId.block(props.entityID).input); 33 if (isSelected) { 34 setTimeout(() => { 35 let input = document.getElementById( 36 elementId.block(props.entityID).input, 37 ); 38 focusElement(input as HTMLInputElement | null); 39 }, 20); 40 } else input?.blur(); 41 }, [isSelected, props.entityID, props.preview]); 42 43 if (url === undefined) { 44 if (!permissions.write) return null; 45 return ( 46 <label 47 className={` 48 w-full h-[104px] p-2 49 text-tertiary hover:text-accent-contrast hover:cursor-pointer 50 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 51 ${isSelected ? "border-2 border-tertiary" : "border border-border"} 52 ${props.pageType === "canvas" && "bg-bg-page"}`} 53 onMouseDown={() => { 54 focusBlock( 55 { type: props.type, value: props.entityID, parent: props.parent }, 56 { type: "start" }, 57 ); 58 }} 59 > 60 <BlockLinkInput {...props} /> 61 </label> 62 ); 63 } 64 65 return ( 66 <a 67 href={url?.data.value} 68 target="_blank" 69 className={` 70 externalLinkBlock flex relative group/linkBlock 71 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline 72 hover:border-accent-contrast shadow-sm 73 ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"} 74 75 `} 76 > 77 <div className="pt-2 pb-2 px-3 grow min-w-0"> 78 <div className="flex flex-col w-full min-w-0 h-full grow "> 79 <div 80 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`} 81 style={{ 82 overflow: "hidden", 83 textOverflow: "ellipsis", 84 wordBreak: "break-all", 85 }} 86 > 87 {title?.data.value} 88 </div> 89 90 <div 91 className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`} 92 > 93 {description?.data.value} 94 </div> 95 <div 96 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 97 className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 98 > 99 {url?.data.value} 100 </div> 101 </div> 102 </div> 103 104 <div 105 className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`} 106 style={{ 107 backgroundImage: `url(${previewImage?.data.src})`, 108 backgroundPosition: "center", 109 }} 110 /> 111 </a> 112 ); 113}; 114 115const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => { 116 let isSelected = useUIState((s) => 117 s.selectedBlocks.find((b) => b.value === props.entityID), 118 ); 119 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 120 let entity_set = useEntitySetContext(); 121 let [linkValue, setLinkValue] = useState(""); 122 let { rep } = useReplicache(); 123 let submit = async () => { 124 let linkEntity = props.entityID; 125 if (!linkEntity) { 126 linkEntity = v7(); 127 128 await rep?.mutate.addBlock({ 129 permission_set: entity_set.set, 130 factID: v7(), 131 parent: props.parent, 132 type: "card", 133 position: generateKeyBetween(props.position, props.nextPosition), 134 newEntityID: linkEntity, 135 }); 136 } 137 let link = linkValue; 138 if (!linkValue.startsWith("http")) link = `https://${linkValue}`; 139 addLinkBlock(link, linkEntity, rep); 140 141 let textEntity = v7(); 142 await rep?.mutate.addBlock({ 143 permission_set: entity_set.set, 144 factID: v7(), 145 parent: props.parent, 146 type: "text", 147 position: generateKeyBetween(props.position, props.nextPosition), 148 newEntityID: textEntity, 149 }); 150 151 focusBlock( 152 { 153 value: textEntity, 154 type: "text", 155 parent: props.parent, 156 }, 157 { type: "start" }, 158 ); 159 }; 160 let smoker = useSmoker(); 161 162 return ( 163 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 164 <> 165 <LinkSmall 166 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 167 /> 168 <Separator /> 169 <Input 170 id={ 171 !props.preview ? elementId.block(props.entityID).input : undefined 172 } 173 type="url" 174 disabled={isLocked} 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 && !isLocked ? "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};