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