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