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, getFontBaseSize, defaultFontId } 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 // When using the default font (Quattro), use var(--font-quattro) which is
197 // always available via next/font/local in layout.tsx, rather than the raw
198 // font-family name 'iA Writer Quattro V' which depends on a dynamic @font-face.
199 // When both heading and body are default, omit the variables entirely so the
200 // CSS fallback var(--theme-font, var(--font-quattro)) resolves naturally.
201 const isDefaultBody = !bodyFontId || bodyFontId === defaultFontId;
202 const isDefaultHeading = !headingFontId || headingFontId === defaultFontId;
203 const headingFontConfig = getFontConfig(headingFontId);
204 const bodyFontConfig = getFontConfig(bodyFontId);
205 const headingFontValue = isDefaultHeading
206 ? (isDefaultBody ? undefined : "var(--font-quattro)")
207 : getFontFamilyValue(headingFontConfig);
208 const bodyFontValue = isDefaultBody
209 ? (isDefaultHeading ? undefined : "var(--font-quattro)")
210 : getFontFamilyValue(bodyFontConfig);
211 const bodyFontBaseSize = isDefaultBody ? undefined : getFontBaseSize(bodyFontConfig);
212 const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig);
213 const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig);
214
215 // Dynamically load Google Fonts when fonts change
216 useEffect(() => {
217 const loadGoogleFont = (url: string | null, fontFamily: string) => {
218 if (!url) return;
219
220 // Check if this font stylesheet is already in the document
221 const existingLink = document.querySelector(`link[href="${url}"]`);
222 if (existingLink) return;
223
224 // Add preconnect hints if not present
225 if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) {
226 const preconnect1 = document.createElement("link");
227 preconnect1.rel = "preconnect";
228 preconnect1.href = "https://fonts.googleapis.com";
229 document.head.appendChild(preconnect1);
230
231 const preconnect2 = document.createElement("link");
232 preconnect2.rel = "preconnect";
233 preconnect2.href = "https://fonts.gstatic.com";
234 preconnect2.crossOrigin = "anonymous";
235 document.head.appendChild(preconnect2);
236 }
237
238 // Load the Google Font stylesheet
239 const link = document.createElement("link");
240 link.rel = "stylesheet";
241 link.href = url;
242 document.head.appendChild(link);
243
244 // Wait for the font to actually load before it gets applied
245 if (document.fonts?.load) {
246 document.fonts.load(`1em "${fontFamily}"`);
247 }
248 };
249
250 loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily);
251 loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily);
252 }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]);
253
254 useEffect(() => {
255 if (local) return;
256 let el = document.querySelector(":root") as HTMLElement;
257 if (!el) return;
258 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet);
259 setCSSVariableToColor(el, "--bg-page", bgPage);
260 document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`;
261 document
262 .querySelector('meta[name="theme-color"]')
263 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`);
264 el?.style.setProperty(
265 "--bg-page-alpha",
266 bgPage.getChannelValue("alpha").toString(),
267 );
268 setCSSVariableToColor(el, "--primary", primary);
269
270 setCSSVariableToColor(el, "--highlight-2", highlight2);
271 setCSSVariableToColor(el, "--highlight-3", highlight3);
272
273 //highlight 1 is special because its default value is a calculated value
274 if (highlight1) {
275 let color = parseColor(`hsba(${highlight1})`);
276 el?.style.setProperty(
277 "--highlight-1",
278 `rgb(${colorToString(color, "rgb")})`,
279 );
280 } else {
281 el?.style.setProperty(
282 "--highlight-1",
283 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
284 );
285 }
286 setCSSVariableToColor(el, "--accent-1", accent1);
287 setCSSVariableToColor(el, "--accent-2", accent2);
288 el?.style.setProperty(
289 "--accent-contrast",
290 colorToString(accentContrast, "rgb"),
291 );
292 el?.style.setProperty(
293 "--accent-1-is-contrast",
294 accentContrast === accent1 ? "1" : "0",
295 );
296
297 // Set page width CSS variable
298 el?.style.setProperty(
299 "--page-width-setting",
300 (pageWidth || 624).toString(),
301 );
302
303 }, [
304 local,
305 bgLeaflet,
306 bgPage,
307 primary,
308 highlight1,
309 highlight2,
310 highlight3,
311 accent1,
312 accent2,
313 accentContrast,
314 pageWidth,
315 ]);
316 return (
317 <div
318 className={`leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch ${className || ""}`}
319 style={
320 {
321 "--bg-leaflet": colorToString(bgLeaflet, "rgb"),
322 "--bg-page": colorToString(bgPage, "rgb"),
323 "--bg-page-alpha": bgPage.getChannelValue("alpha"),
324 "--primary": colorToString(primary, "rgb"),
325 "--accent-1": colorToString(accent1, "rgb"),
326 "--accent-2": colorToString(accent2, "rgb"),
327 "--accent-contrast": colorToString(accentContrast, "rgb"),
328 "--accent-1-is-contrast": accentContrast === accent1 ? 1 : 0,
329 "--highlight-1": highlight1
330 ? `rgb(${colorToString(parseColor(`hsba(${highlight1})`), "rgb")})`
331 : "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 75%)",
332 "--highlight-2": colorToString(highlight2, "rgb"),
333 "--highlight-3": colorToString(highlight3, "rgb"),
334 "--page-width-setting": pageWidth || 624,
335 "--page-width-unitless": pageWidth || 624,
336 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`,
337 "--theme-heading-font": headingFontValue,
338 "--theme-font": bodyFontValue,
339 "--theme-font-base-size": bodyFontBaseSize ? `${bodyFontBaseSize}px` : undefined,
340 } as CSSProperties
341 }
342 >
343 {" "}
344 {children}{" "}
345 </div>
346 );
347};
348
349let CardThemeProviderContext = createContext<null | string>(null);
350export function NestedCardThemeProvider(props: { children: React.ReactNode }) {
351 let card = useContext(CardThemeProviderContext);
352 if (!card) return props.children;
353 return (
354 <CardThemeProvider entityID={card}>{props.children}</CardThemeProvider>
355 );
356}
357
358export function CardThemeProvider(props: {
359 entityID: string;
360 children: React.ReactNode;
361}) {
362 let bgPage = useColorAttributeNullable(
363 props.entityID,
364 "theme/card-background",
365 );
366 let primary = useColorAttributeNullable(props.entityID, "theme/primary");
367 let accent1 = useColorAttributeNullable(
368 props.entityID,
369 "theme/accent-background",
370 );
371 let accent2 = useColorAttributeNullable(props.entityID, "theme/accent-text");
372 let accentContrast =
373 bgPage && accent1 && accent2
374 ? [accent1, accent2].sort((a, b) => {
375 return (
376 getColorDifference(
377 colorToString(b, "rgb"),
378 colorToString(bgPage, "rgb"),
379 ) -
380 getColorDifference(
381 colorToString(a, "rgb"),
382 colorToString(bgPage, "rgb"),
383 )
384 );
385 })[0]
386 : null;
387
388 return (
389 <CardThemeProviderContext.Provider value={props.entityID}>
390 <div
391 className="contents text-primary"
392 style={
393 {
394 "--accent-1": accent1 ? colorToString(accent1, "rgb") : undefined,
395 "--accent-2": accent2 ? colorToString(accent2, "rgb") : undefined,
396 "--accent-contrast": accentContrast
397 ? colorToString(accentContrast, "rgb")
398 : undefined,
399 "--bg-page": bgPage ? colorToString(bgPage, "rgb") : undefined,
400 "--bg-page-alpha": bgPage
401 ? bgPage.getChannelValue("alpha")
402 : undefined,
403 "--primary": primary ? colorToString(primary, "rgb") : undefined,
404 } as CSSProperties
405 }
406 >
407 {props.children}
408 </div>
409 </CardThemeProviderContext.Provider>
410 );
411}
412
413// Wrapper within the Theme Wrapper that provides background image data
414export const ThemeBackgroundProvider = (props: {
415 entityID: string;
416 children: React.ReactNode;
417}) => {
418 let { data: pub, normalizedPublication } = useLeafletPublicationData();
419 let backgroundImage = useEntity(props.entityID, "theme/background-image");
420 let backgroundImageRepeat = useEntity(
421 props.entityID,
422 "theme/background-image-repeat",
423 );
424 if (pub?.publications) {
425 return (
426 <PublicationBackgroundProvider
427 pub_creator={pub?.publications.identity_did || ""}
428 theme={normalizedPublication?.theme}
429 >
430 {props.children}
431 </PublicationBackgroundProvider>
432 );
433 }
434 return (
435 <div
436 className="LeafletBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch"
437 style={
438 {
439 backgroundImage: backgroundImage
440 ? `url(${backgroundImage?.data.src}), url(${backgroundImage?.data.fallback})`
441 : undefined,
442 backgroundPosition: "center",
443 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
444 backgroundSize: !backgroundImageRepeat
445 ? "cover"
446 : backgroundImageRepeat?.data.value,
447 } as CSSProperties
448 }
449 >
450 {props.children}
451 </div>
452 );
453};