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