a tool for shared writing and social publishing
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};