a tool for shared writing and social publishing
1"use client";
2
3import { useEntity, useReplicache } from "src/replicache";
4import { BlockProps } 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(
68 props.position,
69 props.nextPosition,
70 ),
71 newEntityID: entity,
72 });
73 }
74 await rep.mutate.assertFact({
75 entity,
76 attribute: "block/type",
77 data: { type: "block-type-union", value: "image" },
78 });
79 await addImage(file, rep, {
80 entityID: entity,
81 attribute: "block/image",
82 });
83 };
84
85 if (!image) {
86 if (!entity_set.permissions.write) return null;
87 return (
88 <div className="grow w-full">
89 <label
90 className={`
91 group/image-block
92 w-full h-[104px] hover:cursor-pointer p-2
93 text-tertiary hover:text-accent-contrast hover:font-bold
94 flex flex-col items-center justify-center
95 hover:border-2 border-dashed hover:border-accent-contrast rounded-lg
96 ${isSelected && !isLocked ? "border-2 border-tertiary font-bold" : "border border-border"}
97 ${props.pageType === "canvas" && "bg-bg-page"}`}
98 onMouseDown={(e) => e.preventDefault()}
99 onDragOver={(e) => {
100 e.preventDefault();
101 e.stopPropagation();
102 }}
103 onDrop={async (e) => {
104 e.preventDefault();
105 e.stopPropagation();
106 if (isLocked) return;
107 const files = e.dataTransfer.files;
108 if (files && files.length > 0) {
109 const file = files[0];
110 if (file.type.startsWith('image/')) {
111 await handleImageUpload(file);
112 }
113 }
114 }}
115 >
116 <div className="flex gap-2">
117 <BlockImageSmall
118 className={`shrink-0 group-hover/image-block:text-accent-contrast ${isSelected ? "text-tertiary" : "text-border"}`}
119 />
120 Upload An Image
121 </div>
122 <input
123 disabled={isLocked}
124 className="h-0 w-0 hidden"
125 type="file"
126 accept="image/*"
127 onChange={async (e) => {
128 let file = e.currentTarget.files?.[0];
129 if (!file) return;
130 await handleImageUpload(file);
131 }}
132 />
133 </label>
134 </div>
135 );
136 }
137
138 let className = isFullBleed
139 ? ""
140 : isSelected
141 ? "block-border-selected border-transparent! "
142 : "block-border border-transparent!";
143
144 let isLocalUpload = localImages.get(image.data.src);
145
146 return (
147 <div
148 className={`relative group/image
149 ${className}
150 ${isFullBleed && "-mx-3 sm:-mx-4"}
151 ${isFullBleed ? (isFirst ? "-mt-3 sm:-mt-4" : prevIsFullBleed ? "-mt-1" : "") : ""}
152 ${isFullBleed ? (isLast ? "-mb-4" : nextIsFullBleed ? "-mb-2" : "") : ""} `}
153 >
154 {isFullBleed && isSelected ? <FullBleedSelectionIndicator /> : null}
155 {isLocalUpload || image.data.local ? (
156 <img
157 loading="lazy"
158 decoding="async"
159 alt={altText}
160 src={isLocalUpload ? image.data.src + "?local" : image.data.fallback}
161 height={image?.data.height}
162 width={image?.data.width}
163 />
164 ) : (
165 <Image
166 alt={altText || ""}
167 src={
168 "/" + new URL(image.data.src).pathname.split("/").slice(5).join("/")
169 }
170 height={image?.data.height}
171 width={image?.data.width}
172 className={className}
173 />
174 )}
175 {altText !== undefined && !props.preview ? (
176 <ImageAlt entityID={props.value} />
177 ) : null}
178 {!props.preview ? <CoverImageButton entityID={props.value} /> : null}
179 </div>
180 );
181}
182
183export const FullBleedSelectionIndicator = () => {
184 return (
185 <div
186 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`}
187 />
188 );
189};
190
191export const ImageBlockContext = createContext({
192 altEditorOpen: false,
193 setAltEditorOpen: (s: boolean) => {},
194});
195
196const CoverImageButton = (props: { entityID: string }) => {
197 let { rep } = useReplicache();
198 let entity_set = useEntitySetContext();
199 let { data: pubData } = useLeafletPublicationData();
200 let coverImage = useSubscribe(rep, (tx) =>
201 tx.get<string | null>("publication_cover_image"),
202 );
203 let isFocused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
204
205 // Only show if focused, in a publication, has write permissions, and no cover image is set
206 if (!isFocused || !pubData?.publications || !entity_set.permissions.write || coverImage) return null;
207
208 return (
209 <div className="absolute top-2 left-2">
210 <button
211 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"
212 onClick={async (e) => {
213 e.preventDefault();
214 e.stopPropagation();
215 await rep?.mutate.updatePublicationDraft({
216 cover_image: props.entityID,
217 });
218 }}
219 >
220 <span className="w-4 h-4 flex items-center justify-center">
221 <ImageCoverImage />
222 </span>
223 Set as Cover
224 </button>
225 </div>
226 );
227};
228
229const ImageAlt = (props: { entityID: string }) => {
230 let { rep } = useReplicache();
231 let altText = useEntity(props.entityID, "image/alt")?.data.value;
232 let entity_set = useEntitySetContext();
233
234 let setAltEditorOpen = useUIState((s) => s.setOpenPopover);
235 let altEditorOpen = useUIState((s) => s.openPopover === props.entityID);
236
237 if (!entity_set.permissions.write && altText === "") return null;
238 return (
239 <div className="absolute bottom-0 right-2 h-max">
240 <Popover
241 open={altEditorOpen}
242 className="text-sm max-w-xs min-w-0"
243 side="left"
244 asChild
245 trigger={
246 <button
247 onClick={() =>
248 setAltEditorOpen(altEditorOpen ? null : props.entityID)
249 }
250 >
251 <ImageAltSmall fillColor={theme.colors["bg-page"]} />
252 </button>
253 }
254 >
255 {entity_set.permissions.write ? (
256 <AsyncValueAutosizeTextarea
257 className="text-sm text-secondary outline-hidden bg-transparent min-w-0"
258 value={altText}
259 onFocus={(e) => {
260 e.currentTarget.setSelectionRange(
261 e.currentTarget.value.length,
262 e.currentTarget.value.length,
263 );
264 }}
265 onChange={async (e) => {
266 await rep?.mutate.assertFact({
267 entity: props.entityID,
268 attribute: "image/alt",
269 data: { type: "string", value: e.currentTarget.value },
270 });
271 }}
272 placeholder="add alt text..."
273 />
274 ) : (
275 <div className="text-sm text-secondary w-full"> {altText}</div>
276 )}
277 </Popover>
278 </div>
279 );
280};