a tool for shared writing and social publishing
1"use client";
2
3import {
4 ColorPicker as SpectrumColorPicker,
5 parseColor,
6 Color,
7 ColorThumb,
8 ColorSlider,
9 Input,
10 ColorField,
11 SliderTrack,
12 ColorSwatch,
13} from "react-aria-components";
14import { Checkbox } from "components/Checkbox";
15import { useMemo, useState } from "react";
16import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
17import { useColorAttribute } from "components/ThemeManager/useColorAttribute";
18import { Separator } from "components/Layout";
19import { onMouseDown } from "src/utils/iosInputMouseDown";
20import { pickers, setColorAttribute } from "../ThemeSetter";
21import { ImageInput, ImageSettings } from "./ImagePicker";
22
23import { ColorPicker, thumbStyle } from "./ColorPicker";
24import { FontPicker } from "./TextPickers";
25import { BlockImageSmall } from "components/Icons/BlockImageSmall";
26import { Replicache } from "replicache";
27import { CanvasBackgroundPattern } from "components/Canvas";
28import { Toggle } from "components/Toggle";
29import { DeleteSmall } from "components/Icons/DeleteSmall";
30
31export const PageThemePickers = (props: {
32 entityID: string;
33 openPicker: pickers;
34 setOpenPicker: (thisPicker: pickers) => void;
35 home?: boolean;
36 hideFonts?: boolean;
37}) => {
38 let { rep } = useReplicache();
39 let set = useMemo(() => {
40 return setColorAttribute(rep, props.entityID);
41 }, [rep, props.entityID]);
42
43 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
44 let primaryValue = useColorAttribute(props.entityID, "theme/primary");
45 let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value;
46 let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value;
47
48 return (
49 <div
50 className="pageThemeBG flex flex-col gap-2 h-full text-primary bg-bg-leaflet p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))]"
51 style={{ backgroundColor: "rgba(var(--bg-page), 0.6)" }}
52 >
53 {pageType === "canvas" && (
54 <>
55 <CanvasBGPatternPicker entityID={props.entityID} rep={rep} />{" "}
56 <hr className="border-border-light w-full" />
57 </>
58 )}
59 <TextPickers
60 value={primaryValue}
61 setValue={set("theme/primary")}
62 openPicker={props.openPicker}
63 setOpenPicker={props.setOpenPicker}
64 />
65 {!props.home && !props.hideFonts && (
66 <>
67 <FontPicker
68 label="Heading"
69 value={headingFontId}
70 onChange={(fontId) => rep?.mutate.assertFact({ entity: props.entityID, attribute: "theme/heading-font", data: { type: "string", value: fontId } })}
71 />
72 <FontPicker
73 label="Body"
74 value={bodyFontId}
75 onChange={(fontId) => rep?.mutate.assertFact({ entity: props.entityID, attribute: "theme/body-font", data: { type: "string", value: fontId } })}
76 />
77 </>
78 )}
79 </div>
80 );
81};
82
83// Page background picker for subpages - shows Page/Containers color with optional background image
84export const SubpageBackgroundPicker = (props: {
85 entityID: string;
86 openPicker: pickers;
87 setOpenPicker: (p: pickers) => void;
88}) => {
89 let { rep, rootEntity } = useReplicache();
90 let set = useMemo(() => {
91 return setColorAttribute(rep, props.entityID);
92 }, [rep, props.entityID]);
93
94 let pageValue = useColorAttribute(props.entityID, "theme/card-background");
95 let pageBGImage = useEntity(props.entityID, "theme/card-background-image");
96 let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
97 let entityPageBorderHidden = useEntity(
98 props.entityID,
99 "theme/card-border-hidden",
100 );
101 let pageBorderHidden =
102 (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false;
103 let hasPageBackground = !pageBorderHidden;
104
105 // Label is "Page" when page background is visible, "Containers" when hidden
106 let label = hasPageBackground ? "Page" : "Containers";
107
108 // If root page border is hidden, only show color picker (no image support)
109 if (!hasPageBackground) {
110 return (
111 <ColorPicker
112 label={label}
113 helpText={"Affects menus, tooltips and some block backgrounds"}
114 value={pageValue}
115 setValue={set("theme/card-background")}
116 thisPicker="page"
117 openPicker={props.openPicker}
118 setOpenPicker={props.setOpenPicker}
119 closePicker={() => props.setOpenPicker("null")}
120 alpha
121 />
122 );
123 }
124
125 return (
126 <>
127 {pageBGImage && (
128 <SubpageBackgroundImagePicker
129 entityID={props.entityID}
130 openPicker={props.openPicker}
131 setOpenPicker={props.setOpenPicker}
132 setValue={set("theme/card-background")}
133 />
134 )}
135 <div className="relative">
136 <ColorPicker
137 label={label}
138 value={pageValue}
139 setValue={set("theme/card-background")}
140 thisPicker="page"
141 openPicker={props.openPicker}
142 setOpenPicker={props.setOpenPicker}
143 closePicker={() => props.setOpenPicker("null")}
144 alpha
145 />
146 {!pageBGImage && (
147 <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
148 <BlockImageSmall />
149 <div className="hidden">
150 <ImageInput
151 entityID={props.entityID}
152 onChange={() => props.setOpenPicker("page-background-image")}
153 card
154 />
155 </div>
156 </label>
157 )}
158 </div>
159 </>
160 );
161};
162
163const SubpageBackgroundImagePicker = (props: {
164 entityID: string;
165 openPicker: pickers;
166 setOpenPicker: (p: pickers) => void;
167 setValue: (c: Color) => void;
168}) => {
169 let { rep } = useReplicache();
170 let bgImage = useEntity(props.entityID, "theme/card-background-image");
171 let bgRepeat = useEntity(
172 props.entityID,
173 "theme/card-background-image-repeat",
174 );
175 let bgColor = useColorAttribute(props.entityID, "theme/card-background");
176 let bgAlpha =
177 useEntity(props.entityID, "theme/card-background-image-opacity")?.data
178 .value || 1;
179 let alphaColor = useMemo(() => {
180 return parseColor(`rgba(0,0,0,${bgAlpha})`);
181 }, [bgAlpha]);
182 let open = props.openPicker === "page-background-image";
183
184 return (
185 <>
186 <div className="bgPickerColorLabel flex gap-2 items-center">
187 <button
188 onClick={() => {
189 props.setOpenPicker(open ? "null" : "page-background-image");
190 }}
191 className="flex gap-2 items-center grow"
192 >
193 <ColorSwatch
194 color={bgColor}
195 className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
196 style={{
197 backgroundImage: bgImage?.data.src
198 ? `url(${bgImage.data.src})`
199 : undefined,
200 backgroundPosition: "center",
201 backgroundSize: "cover",
202 }}
203 />
204 <strong className="text-[#595959]">Page</strong>
205 <div className="italic text-[#8C8C8C]">image</div>
206 </button>
207
208 <SpectrumColorPicker
209 value={alphaColor}
210 onChange={(c) => {
211 let alpha = c.getChannelValue("alpha");
212 rep?.mutate.assertFact({
213 entity: props.entityID,
214 attribute: "theme/card-background-image-opacity",
215 data: { type: "number", value: alpha },
216 });
217 }}
218 >
219 <Separator classname="h-4! my-1 border-[#C3C3C3]!" />
220 <ColorField className="w-fit pl-[6px]" channel="alpha">
221 <Input
222 onMouseDown={onMouseDown}
223 onFocus={(e) => {
224 e.currentTarget.setSelectionRange(
225 0,
226 e.currentTarget.value.length - 1,
227 );
228 }}
229 onKeyDown={(e) => {
230 if (e.key === "Enter") {
231 e.currentTarget.blur();
232 } else return;
233 }}
234 className="w-[48px] bg-transparent outline-hidden"
235 />
236 </ColorField>
237 </SpectrumColorPicker>
238
239 <div className="flex gap-1 text-[#8C8C8C]">
240 <button
241 onClick={() => {
242 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
243 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
244 }}
245 >
246 <DeleteSmall />
247 </button>
248 <label className="hover:cursor-pointer">
249 <BlockImageSmall />
250 <div className="hidden">
251 <ImageInput
252 entityID={props.entityID}
253 onChange={() => props.setOpenPicker("page-background-image")}
254 card
255 />
256 </div>
257 </label>
258 </div>
259 </div>
260 {open && (
261 <div className="pageImagePicker flex flex-col gap-2">
262 <ImageSettings
263 entityID={props.entityID}
264 card
265 setValue={props.setValue}
266 />
267 <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2">
268 <hr className="border-[#DBDBDB]" />
269 <SpectrumColorPicker
270 value={alphaColor}
271 onChange={(c) => {
272 let alpha = c.getChannelValue("alpha");
273 rep?.mutate.assertFact({
274 entity: props.entityID,
275 attribute: "theme/card-background-image-opacity",
276 data: { type: "number", value: alpha },
277 });
278 }}
279 >
280 <ColorSlider
281 colorSpace="hsb"
282 className="w-full mt-1 rounded-full"
283 style={{
284 backgroundImage: `url(/transparent-bg.png)`,
285 backgroundRepeat: "repeat",
286 backgroundSize: "8px",
287 }}
288 channel="alpha"
289 >
290 <SliderTrack className="h-2 w-full rounded-md">
291 <ColorThumb className={`${thumbStyle} mt-[4px]`} />
292 </SliderTrack>
293 </ColorSlider>
294 </SpectrumColorPicker>
295 </div>
296 </div>
297 )}
298 </>
299 );
300};
301
302// Unified background picker for leaflets - matches structure of BackgroundPicker for publications
303export const LeafletBackgroundPicker = (props: {
304 entityID: string;
305 openPicker: pickers;
306 setOpenPicker: (p: pickers) => void;
307}) => {
308 let { rep } = useReplicache();
309 let set = useMemo(() => {
310 return setColorAttribute(rep, props.entityID);
311 }, [rep, props.entityID]);
312
313 let leafletBgValue = useColorAttribute(
314 props.entityID,
315 "theme/page-background",
316 );
317 let pageValue = useColorAttribute(props.entityID, "theme/card-background");
318 let leafletBGImage = useEntity(props.entityID, "theme/background-image");
319 let leafletBGRepeat = useEntity(
320 props.entityID,
321 "theme/background-image-repeat",
322 );
323 let pageBorderHidden = useEntity(props.entityID, "theme/card-border-hidden");
324 let hasPageBackground = !pageBorderHidden?.data.value;
325
326 // When page background is hidden and no background image, only show the Background picker
327 let showPagePicker = hasPageBackground || !!leafletBGImage;
328
329 return (
330 <>
331 {/* Background color/image picker */}
332 {leafletBGImage ? (
333 <LeafletBackgroundImagePicker
334 entityID={props.entityID}
335 openPicker={props.openPicker}
336 setOpenPicker={props.setOpenPicker}
337 />
338 ) : (
339 <div className="relative">
340 <ColorPicker
341 label="Background"
342 value={leafletBgValue}
343 setValue={set("theme/page-background")}
344 thisPicker="leaflet"
345 openPicker={props.openPicker}
346 setOpenPicker={props.setOpenPicker}
347 closePicker={() => props.setOpenPicker("null")}
348 />
349 <label className="text-[#969696] hover:cursor-pointer shrink-0 absolute top-0 right-0">
350 <BlockImageSmall />
351 <div className="hidden">
352 <ImageInput
353 entityID={props.entityID}
354 onChange={() => props.setOpenPicker("leaflet")}
355 />
356 </div>
357 </label>
358 </div>
359 )}
360
361 {/* Page/Containers color picker - only shown when page background is visible OR there's a bg image */}
362 {showPagePicker && (
363 <ColorPicker
364 label={hasPageBackground ? "Page" : "Containers"}
365 helpText={
366 hasPageBackground
367 ? undefined
368 : "Affects menus, tooltips and some block backgrounds"
369 }
370 value={pageValue}
371 setValue={set("theme/card-background")}
372 thisPicker="page"
373 openPicker={props.openPicker}
374 setOpenPicker={props.setOpenPicker}
375 closePicker={() => props.setOpenPicker("null")}
376 alpha
377 />
378 )}
379
380 <hr className="border-[#CCCCCC]" />
381
382 {/* Page Background toggle */}
383 <PageBorderHider
384 entityID={props.entityID}
385 openPicker={props.openPicker}
386 setOpenPicker={props.setOpenPicker}
387 />
388 </>
389 );
390};
391
392const LeafletBackgroundImagePicker = (props: {
393 entityID: string;
394 openPicker: pickers;
395 setOpenPicker: (p: pickers) => void;
396}) => {
397 let { rep } = useReplicache();
398 let bgImage = useEntity(props.entityID, "theme/background-image");
399 let bgRepeat = useEntity(props.entityID, "theme/background-image-repeat");
400 let bgColor = useColorAttribute(props.entityID, "theme/page-background");
401 let open = props.openPicker === "leaflet";
402
403 return (
404 <>
405 <div className="bgPickerColorLabel flex gap-2 items-center">
406 <button
407 onClick={() => {
408 props.setOpenPicker(open ? "null" : "leaflet");
409 }}
410 className="flex gap-2 items-center grow"
411 >
412 <ColorSwatch
413 color={bgColor}
414 className="w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C]"
415 style={{
416 backgroundImage: bgImage?.data.src
417 ? `url(${bgImage.data.src})`
418 : undefined,
419 backgroundPosition: "center",
420 backgroundSize: "cover",
421 }}
422 />
423 <strong className="text-[#595959]">Background</strong>
424 <div className="italic text-[#8C8C8C]">image</div>
425 </button>
426 <div className="flex gap-1 text-[#8C8C8C]">
427 <button
428 onClick={() => {
429 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
430 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
431 }}
432 >
433 <DeleteSmall />
434 </button>
435 <label className="hover:cursor-pointer">
436 <BlockImageSmall />
437 <div className="hidden">
438 <ImageInput
439 entityID={props.entityID}
440 onChange={() => props.setOpenPicker("leaflet")}
441 />
442 </div>
443 </label>
444 </div>
445 </div>
446 {open && (
447 <div className="pageImagePicker flex flex-col gap-2">
448 <ImageSettings entityID={props.entityID} setValue={() => {}} />
449 </div>
450 )}
451 </>
452 );
453};
454
455export const PageBackgroundColorPicker = (props: {
456 disabled?: boolean;
457 label: string;
458 openPicker: pickers;
459 thisPicker: pickers;
460 setOpenPicker: (thisPicker: pickers) => void;
461 setValue: (c: Color) => void;
462 value: Color;
463 alpha?: boolean;
464 helpText?: string;
465}) => {
466 return (
467 <ColorPicker
468 disabled={props.disabled}
469 label={props.label}
470 helpText={props.helpText}
471 value={props.value}
472 setValue={props.setValue}
473 thisPicker={"page"}
474 openPicker={props.openPicker}
475 setOpenPicker={props.setOpenPicker}
476 closePicker={() => props.setOpenPicker("null")}
477 alpha={props.alpha}
478 />
479 );
480};
481
482export const PageBackgroundImagePicker = (props: {
483 disabled?: boolean;
484 entityID: string;
485 openPicker: pickers;
486 thisPicker: pickers;
487 setOpenPicker: (thisPicker: pickers) => void;
488 closePicker: () => void;
489 setValue: (c: Color) => void;
490 home?: boolean;
491}) => {
492 let bgImage = useEntity(props.entityID, "theme/card-background-image");
493 let bgRepeat = useEntity(
494 props.entityID,
495 "theme/card-background-image-repeat",
496 );
497 let bgColor = useColorAttribute(props.entityID, "theme/card-background");
498 let bgAlpha =
499 useEntity(props.entityID, "theme/card-background-image-opacity")?.data
500 .value || 1;
501 let alphaColor = useMemo(() => {
502 return parseColor(`rgba(0,0,0,${bgAlpha})`);
503 }, [bgAlpha]);
504 let open = props.openPicker == props.thisPicker;
505 let { rep } = useReplicache();
506
507 return (
508 <>
509 <div className="bgPickerColorLabel flex gap-2 items-center">
510 <button
511 disabled={props.disabled}
512 onClick={() => {
513 if (props.openPicker === props.thisPicker) {
514 props.setOpenPicker("null");
515 } else {
516 props.setOpenPicker(props.thisPicker);
517 }
518 }}
519 className="flex gap-2 items-center disabled:text-[#969696]"
520 >
521 <ColorSwatch
522 color={bgColor}
523 className={`w-6 h-6 rounded-full border-2 border-white shadow-[0_0_0_1px_#8C8C8C] ${props.disabled ? "opacity-50" : ""}`}
524 style={{
525 backgroundImage: bgImage?.data.src
526 ? `url(${bgImage.data.src})`
527 : undefined,
528 backgroundPosition: "center",
529 backgroundSize: "cover",
530 }}
531 />
532 <strong
533 className={`${props.disabled ? "text-[#969696]" : " text-[#272727] "}`}
534 >
535 Page
536 </strong>
537 <div className="">Image</div>
538 </button>
539
540 <SpectrumColorPicker
541 value={alphaColor}
542 onChange={(c) => {
543 let alpha = c.getChannelValue("alpha");
544 rep?.mutate.assertFact({
545 entity: props.entityID,
546 attribute: "theme/card-background-image-opacity",
547 data: { type: "number", value: alpha },
548 });
549 }}
550 >
551 <Separator classname="h-4! my-1 border-[#C3C3C3]!" />
552 <ColorField className="w-fit pl-[6px]" channel="alpha">
553 <Input
554 disabled={props.disabled}
555 onMouseDown={onMouseDown}
556 onFocus={(e) => {
557 e.currentTarget.setSelectionRange(
558 0,
559 e.currentTarget.value.length - 1,
560 );
561 }}
562 onKeyDown={(e) => {
563 if (e.key === "Enter") {
564 e.currentTarget.blur();
565 } else return;
566 }}
567 className={`w-[48px] bg-transparent outline-hidden disabled:text-[#969696]`}
568 />
569 </ColorField>
570 </SpectrumColorPicker>
571 <div className="flex gap-1 justify-end grow text-[#969696]">
572 <button
573 onClick={() => {
574 if (bgImage) rep?.mutate.retractFact({ factID: bgImage.id });
575 if (bgRepeat) rep?.mutate.retractFact({ factID: bgRepeat.id });
576 }}
577 >
578 <DeleteSmall />
579 </button>
580 <label>
581 <BlockImageSmall />
582 <div className="hidden">
583 <ImageInput
584 entityID={props.entityID}
585 onChange={() => props.setOpenPicker("page-background-image")}
586 card
587 />
588 </div>
589 </label>
590 </div>
591 </div>
592 {open && (
593 <div className="pageImagePicker flex flex-col gap-2">
594 <ImageSettings
595 entityID={props.entityID}
596 card
597 setValue={props.setValue}
598 />
599 <div className="flex flex-col gap-2 pr-2 pl-8 -mt-2 mb-2">
600 <hr className="border-[#DBDBDB]" />
601 <SpectrumColorPicker
602 value={alphaColor}
603 onChange={(c) => {
604 let alpha = c.getChannelValue("alpha");
605 rep?.mutate.assertFact({
606 entity: props.entityID,
607 attribute: "theme/card-background-image-opacity",
608 data: { type: "number", value: alpha },
609 });
610 }}
611 >
612 <ColorSlider
613 colorSpace="hsb"
614 className="w-full mt-1 rounded-full"
615 style={{
616 backgroundImage: `url(/transparent-bg.png)`,
617 backgroundRepeat: "repeat",
618 backgroundSize: "8px",
619 }}
620 channel="alpha"
621 >
622 <SliderTrack className="h-2 w-full rounded-md">
623 <ColorThumb className={`${thumbStyle} mt-[4px]`} />
624 </SliderTrack>
625 </ColorSlider>
626 </SpectrumColorPicker>
627 </div>
628 </div>
629 )}
630 </>
631 );
632};
633
634const CanvasBGPatternPicker = (props: {
635 entityID: string;
636 rep: Replicache<ReplicacheMutators> | null;
637}) => {
638 let selectedPattern = useEntity(props.entityID, "canvas/background-pattern")
639 ?.data.value;
640 return (
641 <div className="flex gap-2 h-8 ">
642 <button
643 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "grid" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`}
644 onMouseDown={() => {
645 props.rep &&
646 props.rep.mutate.assertFact({
647 entity: props.entityID,
648 attribute: "canvas/background-pattern",
649 data: { type: "canvas-pattern-union", value: "grid" },
650 });
651 }}
652 >
653 <CanvasBackgroundPattern pattern="grid" scale={0.5} />
654 </button>
655 <button
656 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "dot" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`}
657 onMouseDown={() => {
658 props.rep &&
659 props.rep.mutate.assertFact({
660 entity: props.entityID,
661 attribute: "canvas/background-pattern",
662 data: { type: "canvas-pattern-union", value: "dot" },
663 });
664 }}
665 >
666 <CanvasBackgroundPattern pattern="dot" scale={0.5} />
667 </button>
668 <button
669 className={`w-full rounded-md bg-bg-page border ${selectedPattern === "plain" ? "outline-solid outline-tertiary border-tertiary" : "transparent-outline hover:outline-border border-border "}`}
670 onMouseDown={() => {
671 props.rep &&
672 props.rep.mutate.assertFact({
673 entity: props.entityID,
674 attribute: "canvas/background-pattern",
675 data: { type: "canvas-pattern-union", value: "plain" },
676 });
677 }}
678 >
679 <CanvasBackgroundPattern pattern="plain" />
680 </button>
681 </div>
682 );
683};
684
685export const TextPickers = (props: {
686 openPicker: pickers;
687 setOpenPicker: (thisPicker: pickers) => void;
688 value: Color;
689 setValue: (c: Color) => void;
690}) => {
691 return (
692 <ColorPicker
693 label="Text"
694 value={props.value}
695 setValue={props.setValue}
696 thisPicker={"text"}
697 openPicker={props.openPicker}
698 setOpenPicker={props.setOpenPicker}
699 closePicker={() => props.setOpenPicker("null")}
700 />
701 );
702};
703
704export const PageBorderHider = (props: {
705 entityID: string;
706 setOpenPicker: (p: pickers) => void;
707 openPicker: pickers;
708}) => {
709 let { rep, rootEntity } = useReplicache();
710 let rootPageBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
711 let entityPageBorderHidden = useEntity(
712 props.entityID,
713 "theme/card-border-hidden",
714 );
715 let pageBorderHidden =
716 (entityPageBorderHidden || rootPageBorderHidden)?.data.value || false;
717
718 function handleToggle() {
719 rep?.mutate.assertFact({
720 entity: props.entityID,
721 attribute: "theme/card-border-hidden",
722 data: { type: "boolean", value: !pageBorderHidden },
723 });
724
725 (pageBorderHidden && props.openPicker === "page") ||
726 (props.openPicker === "page-background-image" &&
727 props.setOpenPicker("null"));
728 }
729
730 return (
731 <>
732 <Toggle
733 toggle={!pageBorderHidden}
734 onToggle={() => {
735 handleToggle();
736 }}
737 disabledColor1="#8C8C8C"
738 disabledColor2="#DBDBDB"
739 >
740 <div className="flex gap-2">
741 <div className="font-bold">Page Background</div>
742 <div className="italic text-[#8C8C8C]">
743 {pageBorderHidden ? "none" : ""}
744 </div>
745 </div>
746 </Toggle>
747 </>
748 );
749};