a tool for shared writing and social publishing
1"use client";
2
3import React, { JSX, useState } from "react";
4import { useUIState } from "src/useUIState";
5import { useEntitySetContext } from "../EntitySetProvider";
6import { useSearchParams } from "next/navigation";
7
8import { focusBlock } from "src/utils/focusBlock";
9import { elementId } from "src/utils/elementId";
10
11import { Replicache } from "replicache";
12import {
13 Fact,
14 ReplicacheMutators,
15 useEntity,
16 useReferenceToEntity,
17 useReplicache,
18} from "src/replicache";
19
20import { Media } from "../Media";
21import { DesktopPageFooter } from "../DesktopFooter";
22import { ThemePopover } from "../ThemeManager/ThemeSetter";
23import { Canvas } from "../Canvas";
24import { DraftPostOptions } from "../Blocks/MailboxBlock";
25import { Blocks } from "components/Blocks";
26import { MenuItem, Menu } from "../Layout";
27import { scanIndex } from "src/replicache/utils";
28import { PageThemeSetter } from "../ThemeManager/PageThemeSetter";
29import { CardThemeProvider } from "../ThemeManager/ThemeProvider";
30import { PageShareMenu } from "./PageShareMenu";
31import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
32import { useUndoState } from "src/undoManager";
33import { CloseTiny } from "components/Icons/CloseTiny";
34import { MoreOptionsTiny } from "components/Icons/MoreOptionsTiny";
35import { PaintSmall } from "components/Icons/PaintSmall";
36import { ShareSmall } from "components/Icons/ShareSmall";
37import { PublicationMetadata } from "./PublicationMetadata";
38import { useCardBorderHidden } from "./useCardBorderHidden";
39import { useLeafletPublicationData } from "components/PageSWRDataProvider";
40
41export function Pages(props: { rootPage: string }) {
42 let rootPage = useEntity(props.rootPage, "root/page")[0];
43 let pages = useUIState((s) => s.openPages);
44 let params = useSearchParams();
45 let queryRoot = params.get("page");
46 let firstPage = queryRoot || rootPage?.data.value || props.rootPage;
47
48 return (
49 <>
50 <div className="flex items-stretch">
51 <CardThemeProvider entityID={firstPage}>
52 <Page entityID={firstPage} first />
53 </CardThemeProvider>
54 </div>
55 {pages.map((page) => (
56 <div className="flex items-stretch" key={page}>
57 <CardThemeProvider entityID={page}>
58 <Page entityID={page} />
59 </CardThemeProvider>
60 </div>
61 ))}
62 <div
63 className="spacer"
64 style={{ width: `calc(50vw - ((var(--page-width-units)/2))` }}
65 onClick={(e) => {
66 e.currentTarget === e.target && blurPage();
67 }}
68 />
69 </>
70 );
71}
72
73export const LeafletOptions = (props: { entityID: string }) => {
74 return (
75 <>
76 <ThemePopover entityID={props.entityID} />
77 </>
78 );
79};
80
81function Page(props: { entityID: string; first?: boolean }) {
82 let { rep, rootEntity } = useReplicache();
83 let isDraft = useReferenceToEntity("mailbox/draft", props.entityID);
84
85 let isFocused = useUIState((s) => {
86 let focusedElement = s.focusedEntity;
87 let focusedPageID =
88 focusedElement?.entityType === "page"
89 ? focusedElement.entityID
90 : focusedElement?.parent;
91 return focusedPageID === props.entityID;
92 });
93 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
94 let cardBorderHidden = useCardBorderHidden(props.entityID);
95 return (
96 <>
97 {!props.first && (
98 <div
99 className="w-6 lg:snap-center"
100 onClick={(e) => {
101 e.currentTarget === e.target && blurPage();
102 }}
103 />
104 )}
105 <div className="pageWrapper w-fit flex relative snap-center">
106 <div
107 onClick={(e) => {
108 if (e.defaultPrevented) return;
109 if (rep) {
110 if (isFocused) return;
111 focusPage(props.entityID, rep);
112 }
113 }}
114 id={elementId.page(props.entityID).container}
115 style={{
116 width: pageType === "doc" ? "var(--page-width-units)" : undefined,
117 backgroundColor: cardBorderHidden
118 ? ""
119 : "rgba(var(--bg-page), var(--bg-page-alpha))",
120 }}
121 className={`
122 ${pageType === "canvas" ? "!lg:max-w-[1152px]" : "max-w-(--page-width-units)"}
123 page
124 grow flex flex-col
125 overscroll-y-none
126 overflow-y-auto
127 ${cardBorderHidden ? "border-0 shadow-none! sm:-mt-6 sm:-mb-12 -mt-2 -mb-1 pt-3 " : "border rounded-lg"}
128 ${isFocused ? "shadow-md border-border" : "border-border-light"}
129 `}
130 >
131 <Media mobile={true}>
132 <PageOptions entityID={props.entityID} first={props.first} />
133 </Media>
134 <DesktopPageFooter pageID={props.entityID} />
135 {isDraft.length > 0 && (
136 <div
137 className={`pageStatus pt-[6px] pb-1 ${!props.first ? "pr-10 pl-3 sm:px-4" : "px-3 sm:px-4"} border-b border-border text-tertiary`}
138 style={{
139 backgroundColor:
140 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
141 }}
142 >
143 <DraftPostOptions mailboxEntity={isDraft[0].entity} />
144 </div>
145 )}
146
147 <PageContent entityID={props.entityID} />
148 </div>
149 <Media mobile={false}>
150 {isFocused && (
151 <PageOptions entityID={props.entityID} first={props.first} />
152 )}
153 </Media>
154 </div>
155 </>
156 );
157}
158
159const PageContent = (props: { entityID: string }) => {
160 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
161 if (pageType === "doc") return <DocContent entityID={props.entityID} />;
162 return <Canvas entityID={props.entityID} />;
163};
164
165const DocContent = (props: { entityID: string }) => {
166 let { rootEntity } = useReplicache();
167 let isFocused = useUIState((s) => {
168 let focusedElement = s.focusedEntity;
169 let focusedPageID =
170 focusedElement?.entityType === "page"
171 ? focusedElement.entityID
172 : focusedElement?.parent;
173 return focusedPageID === props.entityID;
174 });
175
176 let cardBorderHidden = useCardBorderHidden(props.entityID);
177 let rootBackgroundImage = useEntity(
178 rootEntity,
179 "theme/card-background-image",
180 );
181 let rootBackgroundRepeat = useEntity(
182 rootEntity,
183 "theme/card-background-image-repeat",
184 );
185 let rootBackgroundOpacity = useEntity(
186 rootEntity,
187 "theme/card-background-image-opacity",
188 );
189
190 let cardBackgroundImage = useEntity(
191 props.entityID,
192 "theme/card-background-image",
193 );
194
195 let cardBackgroundImageRepeat = useEntity(
196 props.entityID,
197 "theme/card-background-image-repeat",
198 );
199
200 let cardBackgroundImageOpacity = useEntity(
201 props.entityID,
202 "theme/card-background-image-opacity",
203 );
204
205 let backgroundImage = cardBackgroundImage || rootBackgroundImage;
206 let backgroundImageRepeat = cardBackgroundImage
207 ? cardBackgroundImageRepeat?.data?.value
208 : rootBackgroundRepeat?.data.value;
209 let backgroundImageOpacity = cardBackgroundImage
210 ? cardBackgroundImageOpacity?.data.value
211 : rootBackgroundOpacity?.data.value || 1;
212
213 return (
214 <>
215 {!cardBorderHidden ? (
216 <div
217 className={`pageBackground
218 absolute top-0 left-0 right-0 bottom-0
219 pointer-events-none
220 rounded-lg border
221 ${isFocused ? " border-border" : "border-border-light"}
222 `}
223 style={{
224 backgroundImage: backgroundImage
225 ? `url(${backgroundImage.data.src}), url(${backgroundImage.data.fallback})`
226 : undefined,
227 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
228 backgroundPosition: "center",
229 backgroundSize: !backgroundImageRepeat
230 ? "cover"
231 : backgroundImageRepeat,
232 opacity: backgroundImage?.data.src ? backgroundImageOpacity : 1,
233 }}
234 />
235 ) : null}
236 <PublicationMetadata cardBorderHidden={!!cardBorderHidden} />
237 <Blocks entityID={props.entityID} />
238 {/* we handle page bg in this sepate div so that
239 we can apply an opacity the background image
240 without affecting the opacity of the rest of the page */}
241 </>
242 );
243};
244
245const PageOptionButton = ({
246 children,
247 secondary,
248 cardBorderHidden,
249 className,
250 disabled,
251 ...props
252}: {
253 children: React.ReactNode;
254 secondary?: boolean;
255 cardBorderHidden: boolean | undefined;
256 className?: string;
257 disabled?: boolean;
258} & Omit<JSX.IntrinsicElements["button"], "content">) => {
259 return (
260 <button
261 className={`
262 pageOptionsTrigger
263 shrink-0
264 pt-[2px] h-5 w-5 p-0.5 mx-auto
265 border border-border
266 ${secondary ? "bg-border text-bg-page" : "bg-bg-page text-border"}
267 ${disabled && "opacity-50"}
268 ${cardBorderHidden ? "rounded-md" : `rounded-b-md sm:rounded-l-none sm:rounded-r-md`}
269 flex items-center justify-center
270 ${className}
271
272 `}
273 {...props}
274 >
275 {children}
276 </button>
277 );
278};
279
280const PageOptions = (props: {
281 entityID: string;
282 first: boolean | undefined;
283}) => {
284 let { rootEntity } = useReplicache();
285 let cardBorderHidden = useCardBorderHidden(props.entityID);
286
287 return (
288 <div
289 className={`z-10 w-fit absolute ${cardBorderHidden ? "top-1" : "sm:top-3"} sm:-right-[19px] top-0 right-3 flex sm:flex-col flex-row-reverse gap-1 items-start`}
290 >
291 {!props.first && (
292 <PageOptionButton
293 cardBorderHidden={cardBorderHidden}
294 secondary
295 onClick={() => {
296 useUIState.getState().closePage(props.entityID);
297 }}
298 >
299 <CloseTiny />
300 </PageOptionButton>
301 )}
302 <OptionsMenu
303 entityID={props.entityID}
304 first={!!props.first}
305 cardBorderHidden={cardBorderHidden}
306 />
307 <UndoButtons cardBorderHidden={cardBorderHidden} />
308 </div>
309 );
310};
311
312const UndoButtons = (props: { cardBorderHidden: boolean | undefined }) => {
313 let undoState = useUndoState();
314 let { undoManager } = useReplicache();
315 return (
316 <Media mobile>
317 {undoState.canUndo && (
318 <div className="gap-1 flex sm:flex-col">
319 <PageOptionButton
320 secondary
321 cardBorderHidden={props.cardBorderHidden}
322 onClick={() => undoManager.undo()}
323 >
324 <UndoTiny />
325 </PageOptionButton>
326
327 <PageOptionButton
328 secondary
329 cardBorderHidden={props.cardBorderHidden}
330 onClick={() => undoManager.undo()}
331 disabled={!undoState.canRedo}
332 >
333 <RedoTiny />
334 </PageOptionButton>
335 </div>
336 )}
337 </Media>
338 );
339};
340
341const OptionsMenu = (props: {
342 entityID: string;
343 first: boolean;
344 cardBorderHidden: boolean | undefined;
345}) => {
346 let [state, setState] = useState<"normal" | "theme" | "share">("normal");
347 let { permissions } = useEntitySetContext();
348 if (!permissions.write) return null;
349
350 let { data: pub, mutate } = useLeafletPublicationData();
351 if (pub && props.first) return;
352 return (
353 <Menu
354 align="end"
355 asChild
356 onOpenChange={(open) => {
357 if (!open) setState("normal");
358 }}
359 trigger={
360 <PageOptionButton
361 cardBorderHidden={props.cardBorderHidden}
362 className="w-8! h-5! sm:w-5! sm:h-8!"
363 >
364 <MoreOptionsTiny className="sm:rotate-90" />
365 </PageOptionButton>
366 }
367 >
368 {state === "normal" ? (
369 <>
370 {!props.first && (
371 <MenuItem
372 onSelect={(e) => {
373 e.preventDefault();
374 setState("share");
375 }}
376 >
377 <ShareSmall /> Share Page
378 </MenuItem>
379 )}
380 {!pub && (
381 <MenuItem
382 onSelect={(e) => {
383 e.preventDefault();
384 setState("theme");
385 }}
386 >
387 <PaintSmall /> Theme Page
388 </MenuItem>
389 )}
390 </>
391 ) : state === "theme" ? (
392 <PageThemeSetter entityID={props.entityID} />
393 ) : state === "share" ? (
394 <PageShareMenu entityID={props.entityID} />
395 ) : null}
396 </Menu>
397 );
398};
399
400export async function focusPage(
401 pageID: string,
402 rep: Replicache<ReplicacheMutators>,
403 focusFirstBlock?: "focusFirstBlock",
404) {
405 // if this page is already focused,
406 let focusedBlock = useUIState.getState().focusedEntity;
407 // else set this page as focused
408 useUIState.setState(() => ({
409 focusedEntity: {
410 entityType: "page",
411 entityID: pageID,
412 },
413 }));
414
415 setTimeout(async () => {
416 //scroll to page
417
418 scrollIntoViewIfNeeded(
419 document.getElementById(elementId.page(pageID).container),
420 false,
421 "smooth",
422 );
423
424 // if we asked that the function focus the first block, focus the first block
425 if (focusFirstBlock === "focusFirstBlock") {
426 let firstBlock = await rep.query(async (tx) => {
427 let type = await scanIndex(tx).eav(pageID, "page/type");
428 let blocks = await scanIndex(tx).eav(
429 pageID,
430 type[0]?.data.value === "canvas" ? "canvas/block" : "card/block",
431 );
432
433 let firstBlock = blocks[0];
434
435 if (!firstBlock) {
436 return null;
437 }
438
439 let blockType = (
440 await tx
441 .scan<
442 Fact<"block/type">
443 >({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` })
444 .toArray()
445 )[0];
446
447 if (!blockType) return null;
448
449 return {
450 value: firstBlock.data.value,
451 type: blockType.data.value,
452 parent: firstBlock.entity,
453 position: firstBlock.data.position,
454 };
455 });
456
457 if (firstBlock) {
458 setTimeout(() => {
459 focusBlock(firstBlock, { type: "start" });
460 }, 500);
461 }
462 }
463 }, 50);
464}
465
466const blurPage = () => {
467 useUIState.setState(() => ({
468 focusedEntity: null,
469 selectedBlocks: [],
470 }));
471};
472const UndoTiny = () => {
473 return (
474 <svg
475 width="16"
476 height="16"
477 viewBox="0 0 16 16"
478 fill="none"
479 xmlns="http://www.w3.org/2000/svg"
480 >
481 <path
482 fillRule="evenodd"
483 clipRule="evenodd"
484 d="M5.98775 3.14543C6.37828 2.75491 6.37828 2.12174 5.98775 1.73122C5.59723 1.34069 4.96407 1.34069 4.57354 1.73122L1.20732 5.09744C0.816798 5.48796 0.816798 6.12113 1.20732 6.51165L4.57354 9.87787C4.96407 10.2684 5.59723 10.2684 5.98775 9.87787C6.37828 9.48735 6.37828 8.85418 5.98775 8.46366L4.32865 6.80456H9.6299C12.1732 6.80456 13.0856 8.27148 13.0856 9.21676C13.0856 9.84525 12.8932 10.5028 12.5318 10.9786C12.1942 11.4232 11.6948 11.7367 10.9386 11.7367H9.43173C8.87944 11.7367 8.43173 12.1844 8.43173 12.7367C8.43173 13.2889 8.87944 13.7367 9.43173 13.7367H10.9386C12.3587 13.7367 13.4328 13.0991 14.1246 12.1883C14.7926 11.3086 15.0856 10.2062 15.0856 9.21676C15.0856 6.92612 13.0205 4.80456 9.6299 4.80456L4.32863 4.80456L5.98775 3.14543Z"
485 fill="currentColor"
486 />
487 </svg>
488 );
489};
490
491const RedoTiny = () => {
492 return (
493 <svg
494 width="16"
495 height="16"
496 viewBox="0 0 16 16"
497 fill="none"
498 xmlns="http://www.w3.org/2000/svg"
499 >
500 <path
501 fillRule="evenodd"
502 clipRule="evenodd"
503 d="M10.0122 3.14543C9.62172 2.75491 9.62172 2.12174 10.0122 1.73122C10.4028 1.34069 11.0359 1.34069 11.4265 1.73122L14.7927 5.09744C15.1832 5.48796 15.1832 6.12113 14.7927 6.51165L11.4265 9.87787C11.0359 10.2684 10.4028 10.2684 10.0122 9.87787C9.62172 9.48735 9.62172 8.85418 10.0122 8.46366L11.6713 6.80456H6.3701C3.82678 6.80456 2.91443 8.27148 2.91443 9.21676C2.91443 9.84525 3.10681 10.5028 3.46817 10.9786C3.8058 11.4232 4.30523 11.7367 5.06143 11.7367H6.56827C7.12056 11.7367 7.56827 12.1844 7.56827 12.7367C7.56827 13.2889 7.12056 13.7367 6.56827 13.7367H5.06143C3.6413 13.7367 2.56723 13.0991 1.87544 12.1883C1.20738 11.3086 0.914429 10.2062 0.914429 9.21676C0.914429 6.92612 2.97946 4.80456 6.3701 4.80456L11.6714 4.80456L10.0122 3.14543Z"
504 fill="currentColor"
505 />
506 </svg>
507 );
508};