a tool for shared writing and social publishing
1"use client";
2
3import { createContext, CSSProperties, useContext, useEffect } from "react";
4
5// Context for cardBorderHidden
6export const CardBorderHiddenContext = createContext<boolean>(false);
7
8export function useCardBorderHiddenContext() {
9 return useContext(CardBorderHiddenContext);
10}
11
12// Context for hasBackgroundImage
13export const HasBackgroundImageContext = createContext<boolean>(false);
14
15export function useHasBackgroundImageContext() {
16 return useContext(HasBackgroundImageContext);
17}
18import {
19 colorToString,
20 useColorAttribute,
21 useColorAttributeNullable,
22} from "./useColorAttribute";
23import { Color as AriaColor, parseColor } from "react-aria-components";
24
25import { useEntity } from "src/replicache";
26import { useLeafletPublicationData } from "components/PageSWRDataProvider";
27import {
28 PublicationBackgroundProvider,
29 PublicationThemeProvider,
30} from "./PublicationThemeProvider";
31import { getColorDifference } from "./themeUtils";
32
33// define a function to set an Aria Color to a CSS Variable in RGB
34function setCSSVariableToColor(
35 el: HTMLElement,
36 name: string,
37 value: AriaColor,
38) {
39 el?.style.setProperty(name, colorToString(value, "rgb"));
40}
41
42//Create a wrapper that applies a theme to each page
43export function ThemeProvider(props: {
44 entityID: string | null;
45 local?: boolean;
46 children: React.ReactNode;
47 className?: string;
48}) {
49 let { data: pub, normalizedPublication } = useLeafletPublicationData();
50 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />;
51 return (
52 <PublicationThemeProvider
53 {...props}
54 theme={normalizedPublication?.theme}
55 pub_creator={pub.publications?.identity_did}
56 />
57 );
58}
59// for PUBLICATIONS: define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme
60
61// for LEAFLETS : define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme
62export function LeafletThemeProvider(props: {
63 entityID: string | null;
64 local?: boolean;
65 children: React.ReactNode;
66}) {
67 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
68 let bgPage = useColorAttribute(props.entityID, "theme/card-background");
69 let cardBorderHiddenValue = useEntity(
70 props.entityID,
71 "theme/card-border-hidden",
72 )?.data.value;
73 let showPageBackground = !cardBorderHiddenValue;
74 let backgroundImage = useEntity(props.entityID, "theme/background-image");
75 let hasBackgroundImage = !!backgroundImage;
76 let primary = useColorAttribute(props.entityID, "theme/primary");
77
78 let highlight1 = useEntity(props.entityID, "theme/highlight-1");
79 let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2");
80 let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3");
81
82 let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
83 let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
84
85 let pageWidth = useEntity(props.entityID, "theme/page-width");
86
87 return (
88 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
89 <HasBackgroundImageContext.Provider value={hasBackgroundImage}>
90 <BaseThemeProvider
91 local={props.local}
92 bgLeaflet={bgLeaflet}
93 bgPage={bgPage}
94 primary={primary}
95 highlight2={highlight2}
96 highlight3={highlight3}
97 highlight1={highlight1?.data.value}
98 accent1={accent1}
99 accent2={accent2}
100 showPageBackground={showPageBackground}
101 pageWidth={pageWidth?.data.value}
102 hasBackgroundImage={hasBackgroundImage}
103 >
104 {props.children}
105 </BaseThemeProvider>
106 </HasBackgroundImageContext.Provider>
107 </CardBorderHiddenContext.Provider>
108 );
109}
110
111// handles setting all the Aria Color values to CSS Variables and wrapping the page the theme providers
112export const BaseThemeProvider = ({
113 local,
114 bgLeaflet,
115 bgPage: bgPageProp,
116 primary,
117 accent1,
118 accent2,
119 highlight1,
120 highlight2,
121 highlight3,
122 showPageBackground,
123 pageWidth,
124 hasBackgroundImage,
125 children,
126}: {
127 local?: boolean;
128 showPageBackground?: boolean;
129 hasBackgroundImage?: boolean;
130 bgLeaflet: AriaColor;
131 bgPage: AriaColor;
132 primary: AriaColor;
133 accent1: AriaColor;
134 accent2: AriaColor;
135 highlight1?: string;
136 highlight2: AriaColor;
137 highlight3: AriaColor;
138 pageWidth?: number;
139 children: React.ReactNode;
140}) => {
141 // When showPageBackground is false and there's no background image,
142 // pageBg should inherit from leafletBg
143 const bgPage =
144 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp;
145
146 let accentContrast;
147 let sortedAccents = [accent1, accent2].sort((a, b) => {
148 // sort accents by contrast against the background
149 return (
150 getColorDifference(
151 colorToString(b, "rgb"),
152 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
153 ) -
154 getColorDifference(
155 colorToString(a, "rgb"),
156 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
157 )
158 );
159 });
160 if (
161 // if the contrast-y accent is too similar to text color
162 getColorDifference(
163 colorToString(sortedAccents[0], "rgb"),
164 colorToString(primary, "rgb"),
165 ) < 0.15 &&
166 // and if the other accent is different enough from the background
167 getColorDifference(
168 colorToString(sortedAccents[1], "rgb"),
169 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
170 ) > 0.31
171 ) {
172 //then choose the less contrast-y accent
173 accentContrast = sortedAccents[1];
174 } else {
175 // otherwise, choose the more contrast-y option
176 accentContrast = sortedAccents[0];
177 }
178
179 useEffect(() => {
180 if (local) return;
181 let el = document.querySelector(":root") as HTMLElement;
182 if (!el) return;
183 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet);
184 setCSSVariableToColor(el, "--bg-page", bgPage);
185 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`;
186 document
187 .querySelector('meta[name="theme-color"]')
188 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`);
189 el?.style.setProperty(
190 "--bg-page-alpha",
191 bgPage.getChannelValue("alpha").toString(),
192 );
193 setCSSVariableToColor(el, "--primary", primary);
194
195 setCSSVariableToColor(el, "--highlight-2", highlight2);
196 setCSSVariableToColor(el, "--highlight-3", highlight3);
197
198 //highlight 1 is special because its default value is a calculated value
199 if (highlight1) {
200 let color = parseColor(`hsba(${highlight1})`);
201 el?.style.setProperty(
202 "--highlight-1",
203 `rgb(${colorToString(color, "rgb")})`,
204 );
205 } else {
206 el?.style.setProperty(
207 "--highlight-1",
208 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
209 );
210 }
211 setCSSVariableToColor(el, "--accent-1", accent1);
212 setCSSVariableToColor(el, "--accent-2", accent2);
213 el?.style.setProperty(
214 "--accent-contrast",
215 colorToString(accentContrast, "rgb"),
216 );
217 el?.style.setProperty(
218 "--accent-1-is-contrast",
219 accentContrast === accent1 ? "1" : "0",
220 );
221
222 // Set page width CSS variable
223 el?.style.setProperty(
224 "--page-width-setting",
225 (pageWidth || 624).toString(),
226 );
227 }, [
228 local,
229 bgLeaflet,
230 bgPage,
231 primary,
232 highlight1,
233 highlight2,
234 highlight3,
235 accent1,
236 accent2,
237 accentContrast,
238 pageWidth,
239 ]);
240 return (
241 <div
242 className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch "
243 style={
244 {
245 "--bg-leaflet": colorToString(bgLeaflet, "rgb"),
246 "--bg-page": colorToString(bgPage, "rgb"),
247 "--bg-page-alpha": bgPage.getChannelValue("alpha"),
248 "--primary": colorToString(primary, "rgb"),
249 "--accent-1": colorToString(accent1, "rgb"),
250 "--accent-2": colorToString(accent2, "rgb"),
251 "--accent-contrast": colorToString(accentContrast, "rgb"),
252 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0,
253 "--highlight-1": highlight1
254 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})`
255 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
256 "--highlight-2": colorToString(highlight2, "rgb"),
257 "--highlight-3": colorToString(highlight3, "rgb"),
258 "--page-width-setting": pageWidth || 624,
259 "--page-width-unitless": pageWidth || 624,
260 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`,
261 } as CSSProperties
262 }
263 >
264 {" "}
265 {children}{" "}
266 </div>
267 );
268};
269
270let CardThemeProviderContext = createContext<null | string>(null);
271export function NestedCardThemeProvider(props: { children: React.ReactNode }) {
272 let card = useContext(CardThemeProviderContext);
273 if (!card) return props.children;
274 return (
275 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider>
276 );
277}
278
279export function CardThemeProvider(props: {
280 entityID: string;
281 children: React.ReactNode;
282}) {
283 let bgPage = useColorAttributeNullable(
284 props.entityID,
285 "theme/card-background",
286 );
287 let primary = useColorAttributeNullable(props.entityID, "theme/primary");
288 let accent1 = useColorAttributeNullable(
289 props.entityID,
290 "theme/accent-background",
291 );
292 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text");
293 let accentContrast =
294 bgPage && accent1 && accent2
295 ? [accent1, accent2].sort((a, b) => {
296 return (
297 getColorDifference(
298 colorToString(b, "rgb"),
299 colorToString(bgPage, "rgb"),
300 ) -
301 getColorDifference(
302 colorToString(a, "rgb"),
303 colorToString(bgPage, "rgb"),
304 )
305 );
306 })[0]
307 : null;
308
309 return (
310 <CardThemeProviderContext.Provider value={props.entityID}>
311 <div
312 className="contents text-primary"
313 style={
314 {
315 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined,
316 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined,
317 "--accent-contrast": accentContrast
318 ? colorToString(accentContrast, "rgb")
319 : undefined,
320 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined,
321 "--bg-page-alpha": bgPage
322 ? bgPage.getChannelValue("alpha")
323 : undefined,
324 "--primary": primary ? colorToString(primary, "rgb") : undefined,
325 } as CSSProperties
326 }
327 >
328 {props.children}
329 </div>
330 </CardThemeProviderContext.Provider>
331 );
332}
333
334// Wrapper within the Theme Wrapper that provides background image data
335export const ThemeBackgroundProvider = (props: {
336 entityID: string;
337 children: React.ReactNode;
338}) => {
339 let { data: pub, normalizedPublication } = useLeafletPublicationData();
340 let backgroundImage = useEntity(props.entityID, "theme/background-image");
341 let backgroundImageRepeat = useEntity(
342 props.entityID,
343 "theme/background-image-repeat",
344 );
345 if (pub?.publications) {
346 return (
347 <PublicationBackgroundProvider
348 pub_creator={pub?.publications.identity_did || ""}
349 theme={normalizedPublication?.theme}
350 >
351 {props.children}
352 </PublicationBackgroundProvider>
353 );
354 }
355 return (
356 <div
357 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch"
358 style={
359 {
360 backgroundImage: backgroundImage
361 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})`
362 : undefined,
363 backgroundPosition: "center",
364 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
365 backgroundSize: !backgroundImageRepeat
366 ? "cover"
367 : backgroundImageRepeat?.data.value,
368 } as CSSProperties
369 }
370 >
371 {props.children}
372 </div>
373 );
374};