a tool for shared writing and social publishing
at feature/post-options 287 lines 9.4 kB view raw
1"use client"; 2 3import { useEntity, useReplicache } from "src/replicache"; 4import { BlockProps, BlockLayout } from "./Block"; 5import { useUIState } from "src/useUIState"; 6import Image from "next/image"; 7import { v7 } from "uuid"; 8import { useEntitySetContext } from "components/EntitySetProvider"; 9import { generateKeyBetween } from "fractional-indexing"; 10import { addImage, localImages } from "src/utils/addImage"; 11import { elementId } from "src/utils/elementId"; 12import { createContext, useContext, useEffect, useState } from "react"; 13import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 14import { Popover } from "components/Popover"; 15import { theme } from "tailwind.config"; 16import { EditTiny } from "components/Icons/EditTiny"; 17import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 18import { set } from "colorjs.io/fn"; 19import { ImageAltSmall } from "components/Icons/ImageAlt"; 20import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 21import { useSubscribe } from "src/replicache/useSubscribe"; 22import { ImageCoverImage } from "components/Icons/ImageCoverImage"; 23 24export function ImageBlock(props: BlockProps & { preview?: boolean }) { 25 let { rep } = useReplicache(); 26 let image = useEntity(props.value, "block/image"); 27 let entity_set = useEntitySetContext(); 28 let isSelected = useUIState((s) => 29 s.selectedBlocks.find((b) => b.value === props.value), 30 ); 31 let isLocked = useEntity(props.value, "block/is-locked")?.data.value; 32 let isFullBleed = useEntity(props.value, "image/full-bleed")?.data.value; 33 let isFirst = props.previousBlock === null; 34 let isLast = props.nextBlock === null; 35 36 let altText = useEntity(props.value, "image/alt")?.data.value; 37 38 let nextIsFullBleed = useEntity( 39 props.nextBlock && props.nextBlock.value, 40 "image/full-bleed", 41 )?.data.value; 42 let prevIsFullBleed = useEntity( 43 props.previousBlock && props.previousBlock.value, 44 "image/full-bleed", 45 )?.data.value; 46 47 useEffect(() => { 48 if (props.preview) return; 49 let input = document.getElementById(elementId.block(props.entityID).input); 50 if (isSelected) { 51 input?.focus(); 52 } else { 53 input?.blur(); 54 } 55 }, [isSelected, props.preview, props.entityID]); 56 57 const handleImageUpload = async (file: File) => { 58 if (!rep) return; 59 let entity = props.entityID; 60 if (!entity) { 61 entity = v7(); 62 await rep?.mutate.addBlock({ 63 parent: props.parent, 64 factID: v7(), 65 permission_set: entity_set.set, 66 type: "text", 67 position: generateKeyBetween(props.position, props.nextPosition), 68 newEntityID: entity, 69 }); 70 } 71 await rep.mutate.assertFact({ 72 entity, 73 attribute: "block/type", 74 data: { type: "block-type-union", value: "image" }, 75 }); 76 await addImage(file, rep, { 77 entityID: entity, 78 attribute: "block/image", 79 }); 80 }; 81 82 if (!image) { 83 if (!entity_set.permissions.write) return null; 84 return ( 85 <BlockLayout 86 hasBackground="accent" 87 isSelected={!!isSelected && !isLocked} 88 borderOnHover 89 className=" group/image-block text-tertiary hover:text-accent-contrast hover:font-bold h-[104px] border-dashed rounded-lg" 90 > 91 <label 92 className={` 93 94 w-full h-full hover:cursor-pointer 95 flex flex-col items-center justify-center 96 ${props.pageType === "canvas" && "bg-bg-page"}`} 97 onMouseDown={(e) => e.preventDefault()} 98 onDragOver={(e) => { 99 e.preventDefault(); 100 e.stopPropagation(); 101 }} 102 onDrop={async (e) => { 103 e.preventDefault(); 104 e.stopPropagation(); 105 if (isLocked) return; 106 const files = e.dataTransfer.files; 107 if (files && files.length > 0) { 108 const file = files[0]; 109 if (file.type.startsWith("image/")) { 110 await handleImageUpload(file); 111 } 112 } 113 }} 114 > 115 <div className="flex gap-2"> 116 <BlockImageSmall 117 className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`} 118 /> 119 Upload An Image 120 </div> 121 <input 122 disabled={isLocked} 123 className="h-0 w-0 hidden" 124 type="file" 125 accept="image/*" 126 onChange={async (e) => { 127 let file = e.currentTarget.files?.[0]; 128 if (!file) return; 129 await handleImageUpload(file); 130 }} 131 /> 132 </label> 133 </BlockLayout> 134 ); 135 } 136 137 let imageClassName = isFullBleed 138 ? "" 139 : isSelected 140 ? "block-border-selected border-transparent! " 141 : "block-border border-transparent!"; 142 143 let isLocalUpload = localImages.get(image.data.src); 144 145 let blockClassName = ` 146 relative group/image border-transparent! p-0! w-fit! 147 ${isFullBleed && "-mx-3 sm:-mx-4"} 148 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""} 149 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} 150 `; 151 152 return ( 153 <BlockLayout isSelected={!!isSelected} className={blockClassName}> 154 {isLocalUpload || image.data.local ? ( 155 <img 156 loading="lazy" 157 decoding="async" 158 alt={altText} 159 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback} 160 height={image?.data.height} 161 width={image?.data.width} 162 /> 163 ) : ( 164 <Image 165 alt={altText || ""} 166 src={ 167 "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/") 168 } 169 height={image?.data.height} 170 width={image?.data.width} 171 className={imageClassName} 172 /> 173 )} 174 {altText !== undefined && !props.preview ? ( 175 <ImageAlt entityID={props.value} /> 176 ) : null} 177 {!props.preview ? <CoverImageButton entityID={props.value} /> : null} 178 </BlockLayout> 179 ); 180} 181 182export const FullBleedSelectionIndicator = () => { 183 return ( 184 <div 185 className={`absolute top-3 sm:top-4 bottom-3 sm:bottom-4 left-3 sm:left-4 right-3 sm:right-4 border-2 border-bg-page rounded-lg outline-offset-1 outline-solid outline-2 outline-tertiary`} 186 /> 187 ); 188}; 189 190export const ImageBlockContext = createContext({ 191 altEditorOpen: false, 192 setAltEditorOpen: (s: boolean) => {}, 193}); 194 195const CoverImageButton = (props: { entityID: string }) => { 196 let { rep } = useReplicache(); 197 let entity_set = useEntitySetContext(); 198 let { data: pubData } = useLeafletPublicationData(); 199 let coverImage = useSubscribe(rep, (tx) => 200 tx.get<string | null>("publication_cover_image"), 201 ); 202 let isFocused = useUIState( 203 (s) => s.focusedEntity?.entityID === props.entityID, 204 ); 205 206 // Only show if focused, in a publication, has write permissions, and no cover image is set 207 if ( 208 !isFocused || 209 !pubData?.publications || 210 !entity_set.permissions.write || 211 coverImage 212 ) 213 return null; 214 215 return ( 216 <div className="absolute top-2 left-2"> 217 <button 218 className="flex items-center gap-1 text-xs bg-bg-page/80 hover:bg-bg-page text-secondary hover:text-primary px-2 py-1 rounded-md border border-border hover:border-primary transition-colors" 219 onClick={async (e) => { 220 e.preventDefault(); 221 e.stopPropagation(); 222 await rep?.mutate.updatePublicationDraft({ 223 cover_image: props.entityID, 224 }); 225 }} 226 > 227 <span className="w-4 h-4 flex items-center justify-center"> 228 <ImageCoverImage /> 229 </span> 230 Set as Cover 231 </button> 232 </div> 233 ); 234}; 235 236const ImageAlt = (props: { entityID: string }) => { 237 let { rep } = useReplicache(); 238 let altText = useEntity(props.entityID, "image/alt")?.data.value; 239 let entity_set = useEntitySetContext(); 240 241 let setAltEditorOpen = useUIState((s) => s.setOpenPopover); 242 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID); 243 244 if (!entity_set.permissions.write && altText === "") return null; 245 return ( 246 <div className="absolute bottom-0 right-2 h-max"> 247 <Popover 248 open={altEditorOpen} 249 className="text-sm max-w-xs min-w-0" 250 side="left" 251 asChild 252 trigger={ 253 <button 254 onClick={() => 255 setAltEditorOpen(altEditorOpen ? null : props.entityID) 256 } 257 > 258 <ImageAltSmall fillColor={theme.colors["bg-page"]} /> 259 </button> 260 } 261 > 262 {entity_set.permissions.write ? ( 263 <AsyncValueAutosizeTextarea 264 className="text-sm text-secondary outline-hidden bg-transparent min-w-0" 265 value={altText} 266 onFocus={(e) => { 267 e.currentTarget.setSelectionRange( 268 e.currentTarget.value.length, 269 e.currentTarget.value.length, 270 ); 271 }} 272 onChange={async (e) => { 273 await rep?.mutate.assertFact({ 274 entity: props.entityID, 275 attribute: "image/alt", 276 data: { type: "string", value: e.currentTarget.value }, 277 }); 278 }} 279 placeholder="add alt text..." 280 /> 281 ) : ( 282 <div className="text-sm text-secondary w-full"> {altText}</div> 283 )} 284 </Popover> 285 </div> 286 ); 287};