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";
32import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue, generateFontFaceCSS, getFontBaseSize } from "src/fonts";
33
34// define a function to set an Aria Color to a CSS Variable in RGB
35function setCSSVariableToColor(
36 el: HTMLElement,
37 name: string,
38 value: AriaColor,
39) {
40 el?.style.setProperty(name, colorToString(value, "rgb"));
41}
42
43//Create a wrapper that applies a theme to each page
44export function ThemeProvider(props: {
45 entityID: string | null;
46 local?: boolean;
47 children: React.ReactNode;
48 className?: string;
49 initialHeadingFontId?: string;
50 initialBodyFontId?: string;
51}) {
52 let { data: pub, normalizedPublication } = useLeafletPublicationData();
53 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />;
54 return (
55 <PublicationThemeProvider
56 {...props}
57 theme={normalizedPublication?.theme}
58 pub_creator={pub.publications?.identity_did}
59 />
60 );
61}
62// for PUBLICATIONS: define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme
63
64// for LEAFLETS : define Aria Colors for each value and use BaseThemeProvider to wrap the content of the page in the theme
65export function LeafletThemeProvider(props: {
66 entityID: string | null;
67 local?: boolean;
68 children: React.ReactNode;
69 initialHeadingFontId?: string;
70 initialBodyFontId?: string;
71}) {
72 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
73 let bgPage = useColorAttribute(props.entityID, "theme/card-background");
74 let cardBorderHiddenValue = useEntity(
75 props.entityID,
76 "theme/card-border-hidden",
77 )?.data.value;
78 let showPageBackground = !cardBorderHiddenValue;
79 let backgroundImage = useEntity(props.entityID, "theme/background-image");
80 let hasBackgroundImage = !!backgroundImage;
81 let primary = useColorAttribute(props.entityID, "theme/primary");
82
83 let highlight1 = useEntity(props.entityID, "theme/highlight-1");
84 let highlight2 = useColorAttribute(props.entityID, "theme/highlight-2");
85 let highlight3 = useColorAttribute(props.entityID, "theme/highlight-3");
86
87 let accent1 = useColorAttribute(props.entityID, "theme/accent-background");
88 let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
89
90 let pageWidth = useEntity(props.entityID, "theme/page-width");
91 // Use initial font IDs as fallback until Replicache syncs
92 let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId;
93 let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId;
94
95 return (
96 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
97 <HasBackgroundImageContext.Provider value={hasBackgroundImage}>
98 <BaseThemeProvider
99 local={props.local}
100 bgLeaflet={bgLeaflet}
101 bgPage={bgPage}
102 primary={primary}
103 highlight2={highlight2}
104 highlight3={highlight3}
105 highlight1={highlight1?.data.value}
106 accent1={accent1}
107 accent2={accent2}
108 showPageBackground={showPageBackground}
109 pageWidth={pageWidth?.data.value}
110 hasBackgroundImage={hasBackgroundImage}
111 headingFontId={headingFontId}
112 bodyFontId={bodyFontId}
113 >
114 {props.children}
115 </BaseThemeProvider>
116 </HasBackgroundImageContext.Provider>
117 </CardBorderHiddenContext.Provider>
118 );
119}
120
121// handles setting all the Aria Color values to CSS Variables and wrapping the page the theme providers
122export const BaseThemeProvider = ({
123 local,
124 bgLeaflet,
125 bgPage: bgPageProp,
126 primary,
127 accent1,
128 accent2,
129 highlight1,
130 highlight2,
131 highlight3,
132 showPageBackground,
133 pageWidth,
134 hasBackgroundImage,
135 headingFontId,
136 bodyFontId,
137 className,
138 children,
139}: {
140 local?: boolean;
141 showPageBackground?: boolean;
142 hasBackgroundImage?: boolean;
143 bgLeaflet: AriaColor;
144 bgPage: AriaColor;
145 primary: AriaColor;
146 accent1: AriaColor;
147 accent2: AriaColor;
148 highlight1?: string;
149 highlight2: AriaColor;
150 highlight3: AriaColor;
151 pageWidth?: number;
152 headingFontId?: string;
153 bodyFontId?: string;
154 className?: string;
155 children: React.ReactNode;
156}) => {
157 // When showPageBackground is false and there's no background image,
158 // pageBg should inherit from leafletBg
159 const bgPage =
160 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp;
161
162 let accentContrast;
163 let sortedAccents = [accent1, accent2].sort((a, b) => {
164 // sort accents by contrast against the background
165 return (
166 getColorDifference(
167 colorToString(b, "rgb"),
168 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
169 ) -
170 getColorDifference(
171 colorToString(a, "rgb"),
172 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
173 )
174 );
175 });
176 if (
177 // if the contrast-y accent is too similar to text color
178 getColorDifference(
179 colorToString(sortedAccents[0], "rgb"),
180 colorToString(primary, "rgb"),
181 ) < 0.15 &&
182 // and if the other accent is different enough from the background
183 getColorDifference(
184 colorToString(sortedAccents[1], "rgb"),
185 colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
186 ) > 0.31
187 ) {
188 //then choose the less contrast-y accent
189 accentContrast = sortedAccents[1];
190 } else {
191 // otherwise, choose the more contrast-y option
192 accentContrast = sortedAccents[0];
193 }
194
195 // Get font configs for CSS variables
196 const headingFontConfig = getFontConfig(headingFontId);
197 const bodyFontConfig = getFontConfig(bodyFontId);
198 const headingFontValue = getFontFamilyValue(headingFontConfig);
199 const bodyFontValue = getFontFamilyValue(bodyFontConfig);
200 const bodyFontBaseSize = getFontBaseSize(bodyFontConfig);
201 const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig);
202 const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig);
203
204 // Dynamically load Google Fonts when fonts change
205 useEffect(() => {
206 const loadGoogleFont = (url: string | null, fontFamily: string) => {
207 if (!url) return;
208
209 // Check if this font stylesheet is already in the document
210 const existingLink = document.querySelector(`link[href="${url}"]`);
211 if (existingLink) return;
212
213 // Add preconnect hints if not present
214 if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) {
215 const preconnect1 = document.createElement("link");
216 preconnect1.rel = "preconnect";
217 preconnect1.href = "https://fonts.googleapis.com";
218 document.head.appendChild(preconnect1);
219
220 const preconnect2 = document.createElement("link");
221 preconnect2.rel = "preconnect";
222 preconnect2.href = "https://fonts.gstatic.com";
223 preconnect2.crossOrigin = "anonymous";
224 document.head.appendChild(preconnect2);
225 }
226
227 // Load the Google Font stylesheet
228 const link = document.createElement("link");
229 link.rel = "stylesheet";
230 link.href = url;
231 document.head.appendChild(link);
232
233 // Wait for the font to actually load before it gets applied
234 if (document.fonts?.load) {
235 document.fonts.load(`1em "${fontFamily}"`);
236 }
237 };
238
239 loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily);
240 loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily);
241 }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]);
242
243 useEffect(() => {
244 if (local) return;
245 let el = document.querySelector(":root") as HTMLElement;
246 if (!el) return;
247 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet);
248 setCSSVariableToColor(el, "--bg-page", bgPage);
249 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`;
250 document
251 .querySelector('meta[name="theme-color"]')
252 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`);
253 el?.style.setProperty(
254 "--bg-page-alpha",
255 bgPage.getChannelValue("alpha").toString(),
256 );
257 setCSSVariableToColor(el, "--primary", primary);
258
259 setCSSVariableToColor(el, "--highlight-2", highlight2);
260 setCSSVariableToColor(el, "--highlight-3", highlight3);
261
262 //highlight 1 is special because its default value is a calculated value
263 if (highlight1) {
264 let color = parseColor(`hsba(${highlight1})`);
265 el?.style.setProperty(
266 "--highlight-1",
267 `rgb(${colorToString(color, "rgb")})`,
268 );
269 } else {
270 el?.style.setProperty(
271 "--highlight-1",
272 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
273 );
274 }
275 setCSSVariableToColor(el, "--accent-1", accent1);
276 setCSSVariableToColor(el, "--accent-2", accent2);
277 el?.style.setProperty(
278 "--accent-contrast",
279 colorToString(accentContrast, "rgb"),
280 );
281 el?.style.setProperty(
282 "--accent-1-is-contrast",
283 accentContrast === accent1 ? "1" : "0",
284 );
285
286 // Set page width CSS variable
287 el?.style.setProperty(
288 "--page-width-setting",
289 (pageWidth || 624).toString(),
290 );
291
292 }, [
293 local,
294 bgLeaflet,
295 bgPage,
296 primary,
297 highlight1,
298 highlight2,
299 highlight3,
300 accent1,
301 accent2,
302 accentContrast,
303 pageWidth,
304 ]);
305 return (
306 <div
307 className={`leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch ${className || ""}`}
308 style={
309 {
310 "--bg-leaflet": colorToString(bgLeaflet, "rgb"),
311 "--bg-page": colorToString(bgPage, "rgb"),
312 "--bg-page-alpha": bgPage.getChannelValue("alpha"),
313 "--primary": colorToString(primary, "rgb"),
314 "--accent-1": colorToString(accent1, "rgb"),
315 "--accent-2": colorToString(accent2, "rgb"),
316 "--accent-contrast": colorToString(accentContrast, "rgb"),
317 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0,
318 "--highlight-1": highlight1
319 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})`
320 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
321 "--highlight-2": colorToString(highlight2, "rgb"),
322 "--highlight-3": colorToString(highlight3, "rgb"),
323 "--page-width-setting": pageWidth || 624,
324 "--page-width-unitless": pageWidth || 624,
325 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`,
326 "--theme-heading-font": headingFontValue,
327 "--theme-font": bodyFontValue,
328 "--theme-font-base-size": `${bodyFontBaseSize}px`,
329 } as CSSProperties
330 }
331 >
332 {" "}
333 {children}{" "}
334 </div>
335 );
336};
337
338let CardThemeProviderContext = createContext<null | string>(null);
339export function NestedCardThemeProvider(props: { children: React.ReactNode }) {
340 let card = useContext(CardThemeProviderContext);
341 if (!card) return props.children;
342 return (
343 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider>
344 );
345}
346
347export function CardThemeProvider(props: {
348 entityID: string;
349 children: React.ReactNode;
350}) {
351 let bgPage = useColorAttributeNullable(
352 props.entityID,
353 "theme/card-background",
354 );
355 let primary = useColorAttributeNullable(props.entityID, "theme/primary");
356 let accent1 = useColorAttributeNullable(
357 props.entityID,
358 "theme/accent-background",
359 );
360 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text");
361 let accentContrast =
362 bgPage && accent1 && accent2
363 ? [accent1, accent2].sort((a, b) => {
364 return (
365 getColorDifference(
366 colorToString(b, "rgb"),
367 colorToString(bgPage, "rgb"),
368 ) -
369 getColorDifference(
370 colorToString(a, "rgb"),
371 colorToString(bgPage, "rgb"),
372 )
373 );
374 })[0]
375 : null;
376
377 return (
378 <CardThemeProviderContext.Provider value={props.entityID}>
379 <div
380 className="contents text-primary"
381 style={
382 {
383 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined,
384 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined,
385 "--accent-contrast": accentContrast
386 ? colorToString(accentContrast, "rgb")
387 : undefined,
388 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined,
389 "--bg-page-alpha": bgPage
390 ? bgPage.getChannelValue("alpha")
391 : undefined,
392 "--primary": primary ? colorToString(primary, "rgb") : undefined,
393 } as CSSProperties
394 }
395 >
396 {props.children}
397 </div>
398 </CardThemeProviderContext.Provider>
399 );
400}
401
402// Wrapper within the Theme Wrapper that provides background image data
403export const ThemeBackgroundProvider = (props: {
404 entityID: string;
405 children: React.ReactNode;
406}) => {
407 let { data: pub, normalizedPublication } = useLeafletPublicationData();
408 let backgroundImage = useEntity(props.entityID, "theme/background-image");
409 let backgroundImageRepeat = useEntity(
410 props.entityID,
411 "theme/background-image-repeat",
412 );
413 if (pub?.publications) {
414 return (
415 <PublicationBackgroundProvider
416 pub_creator={pub?.publications.identity_did || ""}
417 theme={normalizedPublication?.theme}
418 >
419 {props.children}
420 </PublicationBackgroundProvider>
421 );
422 }
423 return (
424 <div
425 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch"
426 style={
427 {
428 backgroundImage: backgroundImage
429 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})`
430 : undefined,
431 backgroundPosition: "center",
432 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
433 backgroundSize: !backgroundImageRepeat
434 ? "cover"
435 : backgroundImageRepeat?.data.value,
436 } as CSSProperties
437 }
438 >
439 {props.children}
440 </div>
441 );
442};