a tool for shared writing and social publishing
1import {
2 usePublicationData,
3 useNormalizedPublicationRecord,
4} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
5import { useState } from "react";
6import { pickers, SectionArrow } from "./ThemeSetter";
7import { Color } from "react-aria-components";
8import { PubLeafletThemeBackgroundImage } from "lexicons/api";
9import { AtUri } from "@atproto/syntax";
10import { useLocalPubTheme } from "./PublicationThemeProvider";
11import { BaseThemeProvider } from "./ThemeProvider";
12import { blobRefToSrc } from "src/utils/blobRefToSrc";
13import { updatePublicationTheme } from "app/lish/createPub/updatePublication";
14import { PagePickers } from "./PubPickers/PubTextPickers";
15import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
16import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
17import { Separator } from "components/Layout";
18import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings";
19import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
20import { useToaster } from "components/Toast";
21import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
22import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter";
23import { FontPicker } from "./Pickers/TextPickers";
24
25export type ImageState = {
26 src: string;
27 file?: File;
28 repeat: number | null;
29};
30export const PubThemeSetter = (props: {
31 backToMenu: () => void;
32 loading: boolean;
33 setLoading: (l: boolean) => void;
34}) => {
35 let [sample, setSample] = useState<"pub" | "post">("pub");
36 let [openPicker, setOpenPicker] = useState<pickers>("null");
37 let { data, mutate } = usePublicationData();
38 let { publication: pub } = data || {};
39 let record = useNormalizedPublicationRecord();
40 let [showPageBackground, setShowPageBackground] = useState(
41 !!record?.theme?.showPageBackground,
42 );
43 let {
44 theme: localPubTheme,
45 setTheme,
46 changes,
47 } = useLocalPubTheme(record?.theme, showPageBackground);
48 let [image, setImage] = useState<ImageState | null>(
49 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage)
50 ? {
51 src: blobRefToSrc(
52 record.theme.backgroundImage.image.ref,
53 pub?.identity_did!,
54 ),
55 repeat: record.theme.backgroundImage.repeat
56 ? record.theme.backgroundImage.width || 500
57 : null,
58 }
59 : null,
60 );
61 let [pageWidth, setPageWidth] = useState<number>(
62 record?.theme?.pageWidth || 624,
63 );
64 let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont);
65 let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont);
66 let pubBGImage = image?.src || null;
67 let leafletBGRepeat = image?.repeat || null;
68 let toaster = useToaster();
69
70 return (
71 <BaseThemeProvider
72 local
73 {...localPubTheme}
74 headingFontId={headingFont}
75 bodyFontId={bodyFont}
76 hasBackgroundImage={!!image}
77 className="min-h-0!"
78 >
79 <div className="min-h-0 flex-1 flex flex-col pb-0.5">
80 <form
81 className="flex-shrink-0"
82 onSubmit={async (e) => {
83 e.preventDefault();
84 if (!pub) return;
85 props.setLoading(true);
86 let result = await updatePublicationTheme({
87 uri: pub.uri,
88 theme: {
89 pageBackground: ColorToRGBA(localPubTheme.bgPage),
90 showPageBackground: showPageBackground,
91 backgroundColor: image
92 ? ColorToRGBA(localPubTheme.bgLeaflet)
93 : ColorToRGB(localPubTheme.bgLeaflet),
94 backgroundRepeat: image?.repeat,
95 backgroundImage: image ? image.file : null,
96 pageWidth: pageWidth,
97 primary: ColorToRGB(localPubTheme.primary),
98 accentBackground: ColorToRGB(localPubTheme.accent1),
99 accentText: ColorToRGB(localPubTheme.accent2),
100 headingFont: headingFont,
101 bodyFont: bodyFont,
102 },
103 });
104
105 if (!result.success) {
106 props.setLoading(false);
107 if (result.error && isOAuthSessionError(result.error)) {
108 toaster({
109 content: <OAuthErrorMessage error={result.error} />,
110 type: "error",
111 });
112 } else {
113 toaster({
114 content: "Failed to update theme",
115 type: "error",
116 });
117 }
118 return;
119 }
120
121 mutate((pub) => {
122 if (result.publication && pub?.publication)
123 return {
124 ...pub,
125 publication: { ...pub.publication, ...result.publication },
126 };
127 return pub;
128 }, false);
129 props.setLoading(false);
130 }}
131 >
132 <PubSettingsHeader
133 loading={props.loading}
134 setLoadingAction={props.setLoading}
135 backToMenuAction={props.backToMenu}
136 >
137 Theme and Layout
138 </PubSettingsHeader>
139 </form>
140
141 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 ">
142 <PubPageWidthSetter
143 pageWidth={pageWidth}
144 setPageWidth={setPageWidth}
145 thisPicker="page-width"
146 openPicker={openPicker}
147 setOpenPicker={setOpenPicker}
148 />
149 <div className="themeBGLeaflet flex flex-col">
150 <div
151 className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
152 >
153 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 mt-1 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
154 <BackgroundPicker
155 bgImage={image}
156 setBgImage={setImage}
157 backgroundColor={localPubTheme.bgLeaflet}
158 pageBackground={localPubTheme.bgPage}
159 setPageBackground={(color) => {
160 setTheme((t) => ({ ...t, bgPage: color }));
161 }}
162 setBackgroundColor={(color) => {
163 setTheme((t) => ({ ...t, bgLeaflet: color }));
164 }}
165 openPicker={openPicker}
166 setOpenPicker={setOpenPicker}
167 hasPageBackground={!!showPageBackground}
168 setHasPageBackground={setShowPageBackground}
169 />
170 </div>
171
172 <SectionArrow
173 fill="white"
174 stroke="#CCCCCC"
175 className="ml-2 -mt-[1px]"
176 />
177 </div>
178 </div>
179
180 <div
181 style={{
182 backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined,
183 backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat",
184 backgroundPosition: "center",
185 backgroundSize: !leafletBGRepeat
186 ? "cover"
187 : `calc(${leafletBGRepeat}px / 2 )`,
188 }}
189 className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `}
190 >
191 <div className={`flex flex-col gap-3 z-10`}>
192 <PagePickers
193 pageBackground={localPubTheme.bgPage}
194 primary={localPubTheme.primary}
195 setPageBackground={(color) => {
196 setTheme((t) => ({ ...t, bgPage: color }));
197 }}
198 setPrimary={(color) => {
199 setTheme((t) => ({ ...t, primary: color }));
200 }}
201 openPicker={openPicker}
202 setOpenPicker={(pickers) => setOpenPicker(pickers)}
203 hasPageBackground={showPageBackground}
204 />
205 <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1">
206 <FontPicker
207 label="Heading"
208 value={headingFont}
209 onChange={setHeadingFont}
210 />
211 <FontPicker
212 label="Body"
213 value={bodyFont}
214 onChange={setBodyFont}
215 />
216 </div>
217 <PubAccentPickers
218 accent1={localPubTheme.accent1}
219 setAccent1={(color) => {
220 setTheme((t) => ({ ...t, accent1: color }));
221 }}
222 accent2={localPubTheme.accent2}
223 setAccent2={(color) => {
224 setTheme((t) => ({ ...t, accent2: color }));
225 }}
226 openPicker={openPicker}
227 setOpenPicker={(pickers) => setOpenPicker(pickers)}
228 />
229 </div>
230 </div>
231 <div className="flex flex-col mt-4 ">
232 <div className="flex gap-2 items-center text-sm text-[#8C8C8C]">
233 <div className="text-sm">Preview</div>
234 <Separator classname="h-4!" />{" "}
235 <button
236 className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`}
237 onClick={() => setSample("pub")}
238 >
239 Pub
240 </button>
241 <button
242 className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`}
243 onClick={() => setSample("post")}
244 >
245 Post
246 </button>
247 </div>
248 {sample === "pub" ? (
249 <SamplePub
250 pubBGImage={pubBGImage}
251 pubBGRepeat={leafletBGRepeat}
252 showPageBackground={showPageBackground}
253 />
254 ) : (
255 <SamplePost
256 pubBGImage={pubBGImage}
257 pubBGRepeat={leafletBGRepeat}
258 showPageBackground={showPageBackground}
259 />
260 )}
261 </div>
262 </div>
263 </div>
264 </BaseThemeProvider>
265 );
266};
267
268const SamplePub = (props: {
269 pubBGImage: string | null;
270 pubBGRepeat: number | null;
271 showPageBackground: boolean;
272}) => {
273 let { data } = usePublicationData();
274 let { publication } = data || {};
275 let record = useNormalizedPublicationRecord();
276
277 return (
278 <div
279 style={{
280 backgroundImage: props.pubBGImage
281 ? `url(${props.pubBGImage})`
282 : undefined,
283 backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat",
284 backgroundPosition: "center",
285 backgroundSize: !props.pubBGRepeat
286 ? "cover"
287 : `calc(${props.pubBGRepeat}px / 2 )`,
288 }}
289 className={`bg-bg-leaflet p-3 pb-0 flex flex-col gap-3 rounded-t-md border border-border border-b-0 h-[148px] overflow-hidden `}
290 >
291 <div
292 className="pubWrapper sampleContent rounded-t-md border-border pb-4 px-[10px] flex flex-col gap-[14px] w-[250px] mx-auto"
293 style={{
294 background: props.showPageBackground
295 ? "rgba(var(--bg-page), var(--bg-page-alpha))"
296 : undefined,
297 }}
298 >
299 <div className="flex flex-col justify-center text-center pt-2">
300 {record?.icon && publication?.uri && (
301 <div
302 style={{
303 backgroundRepeat: "no-repeat",
304 backgroundPosition: "center",
305 backgroundSize: "cover",
306 backgroundImage: `url(/api/atproto_images?did=${new AtUri(publication.uri).host}&cid=${(record.icon?.ref as unknown as { $link: string })["$link"]})`,
307 }}
308 className="w-4 h-4 rounded-full place-self-center"
309 />
310 )}
311
312 <div className="text-[11px] font-bold pt-[5px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>
313 {record?.name}
314 </div>
315 <div className="text-[7px] font-normal text-tertiary">
316 {record?.description}
317 </div>
318 <div className=" flex gap-1 items-center mt-[6px] bg-accent-1 text-accent-2 py-px px-[4px] text-[7px] w-fit font-bold rounded-[2px] mx-auto">
319 <div className="h-[7px] w-[7px] rounded-full bg-accent-2" />
320 Subscribe with Bluesky
321 </div>
322 </div>
323
324 <div className="flex flex-col text-[8px] rounded-md ">
325 <div className="font-bold" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>A Sample Post</div>
326 <div className="text-secondary italic text-[6px]">
327 This is a sample description about the sample post
328 </div>
329 <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div>
330 </div>
331 </div>
332 </div>
333 );
334};
335
336const SamplePost = (props: {
337 pubBGImage: string | null;
338 pubBGRepeat: number | null;
339 showPageBackground: boolean;
340}) => {
341 let { data } = usePublicationData();
342 let { publication } = data || {};
343 let record = useNormalizedPublicationRecord();
344 return (
345 <div
346 style={{
347 backgroundImage: props.pubBGImage
348 ? `url(${props.pubBGImage})`
349 : undefined,
350 backgroundRepeat: props.pubBGRepeat ? "repeat" : "no-repeat",
351 backgroundPosition: "center",
352 backgroundSize: !props.pubBGRepeat
353 ? "cover"
354 : `calc(${props.pubBGRepeat}px / 2 )`,
355 }}
356 className={`bg-bg-leaflet p-3 max-w-full flex flex-col gap-3 rounded-t-md border border-border border-b-0 pb-0 h-[148px] overflow-hidden`}
357 >
358 <div
359 className="pubWrapper sampleContent rounded-t-md border-border pb-0 px-[6px] flex flex-col w-[250px] mx-auto"
360 style={{
361 background: props.showPageBackground
362 ? "rgba(var(--bg-page), var(--bg-page-alpha))"
363 : undefined,
364 }}
365 >
366 <div className="flex flex-col ">
367 <div className="text-[6px] font-bold pt-[6px] text-accent-contrast" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>
368 {record?.name}
369 </div>
370 <div className="text-[11px] font-bold text-primary" style={{ fontFamily: "var(--theme-heading-font, var(--theme-font, var(--font-quattro)))" }}>
371 A Sample Post
372 </div>
373 <div className="text-[7px] font-normal text-secondary italic">
374 A short sample description about the sample post
375 </div>
376 <div className="text-tertiary text-[5px] pt-[2px]">Jan 1, 20XX </div>
377 </div>
378 <div className="text-[6px] pt-[8px] flex flex-col gap-[6px]">
379 <div>
380 Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque
381 faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi
382 pretium tellus duis convallis. Tempus leo eu aenean sed diam urna
383 tempor.
384 </div>
385
386 <div>
387 Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis
388 massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit
389 semper vel class aptent taciti sociosqu. Ad litora torquent per
390 conubia nostra inceptos himenaeos.
391 </div>
392 <div>
393 Sed et nisi semper, egestas purus a, egestas nulla. Nulla ultricies,
394 purus non dapibus tincidunt, nunc sem rhoncus sem, vel malesuada
395 tellus enim sit amet magna. Donec ac justo a ipsum fermentum
396 vulputate. Etiam sit amet viverra leo. Aenean accumsan consectetur
397 velit. Vivamus at justo a nisl imperdiet dictum. Donec scelerisque
398 ex eget turpis scelerisque tincidunt. Proin non convallis nibh, eget
399 aliquet ex. Curabitur ornare a ipsum in ultrices.
400 </div>
401 </div>
402 </div>
403 </div>
404 );
405};