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 { PubLeafletThemeBackgroundImage } from "lexicons/api";
8import { AtUri } from "@atproto/syntax";
9import { useLocalPubTheme } from "./PublicationThemeProvider";
10import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
11import { blobRefToSrc } from "src/utils/blobRefToSrc";
12import { updatePublicationTheme } from "app/lish/createPub/updatePublication";
13import { PagePickers } from "./PubPickers/PubTextPickers";
14import { BackgroundPicker } from "./PubPickers/PubBackgroundPickers";
15import { PubAccentPickers } from "./PubPickers/PubAcccentPickers";
16import { Separator } from "components/Layout";
17
18import { ColorToRGB, ColorToRGBA } from "./colorToLexicons";
19import { useToaster } from "components/Toast";
20import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
21import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter";
22import { FontPicker } from "./Pickers/TextPickers";
23import { GoToArrow } from "components/Icons/GoToArrow";
24import { ButtonPrimary } from "components/Buttons";
25import { PresetThemePicker } from "./PubPickers/PubPresetPicker";
26
27export type ImageState = {
28 src: string;
29 file?: File;
30 repeat: number | null;
31};
32
33export function usePubThemeEditorState() {
34 let [openPicker, setOpenPicker] = useState<pickers>("null");
35 let { data, mutate } = usePublicationData();
36 let { publication: pub } = data || {};
37 let record = useNormalizedPublicationRecord();
38 let [showPageBackground, setShowPageBackground] = useState(
39 !!record?.theme?.showPageBackground,
40 );
41 let {
42 theme: localPubTheme,
43 setTheme,
44 changes,
45 resetChanges,
46 } = useLocalPubTheme(record?.theme, showPageBackground);
47 let [image, setImage] = useState<ImageState | null>(
48 PubLeafletThemeBackgroundImage.isMain(record?.theme?.backgroundImage)
49 ? {
50 src: blobRefToSrc(
51 record.theme.backgroundImage.image.ref,
52 pub?.identity_did!,
53 ),
54 repeat: record.theme.backgroundImage.repeat
55 ? record.theme.backgroundImage.width || 500
56 : null,
57 }
58 : null,
59 );
60 let [pageWidth, setPageWidth] = useState<number>(
61 record?.theme?.pageWidth || 624,
62 );
63 let [headingFont, setHeadingFont] = useState<string | undefined>(
64 record?.theme?.headingFont,
65 );
66 let [bodyFont, setBodyFont] = useState<string | undefined>(
67 record?.theme?.bodyFont,
68 );
69 let pubBGImage = image?.src || null;
70 let leafletBGRepeat = image?.repeat || null;
71 let toaster = useToaster();
72
73 let submitTheme = async (setLoading: (l: boolean) => void) => {
74 if (!pub) return;
75 setLoading(true);
76 let result = await updatePublicationTheme({
77 uri: pub.uri,
78 theme: {
79 pageBackground: ColorToRGBA(localPubTheme.bgPage),
80 showPageBackground: showPageBackground,
81 backgroundColor: image
82 ? ColorToRGBA(localPubTheme.bgLeaflet)
83 : ColorToRGB(localPubTheme.bgLeaflet),
84 backgroundRepeat: image?.repeat,
85 backgroundImage: image ? image.file : null,
86 pageWidth: pageWidth,
87 primary: ColorToRGB(localPubTheme.primary),
88 accentBackground: ColorToRGB(localPubTheme.accent1),
89 accentText: ColorToRGB(localPubTheme.accent2),
90 headingFont: headingFont,
91 bodyFont: bodyFont,
92 },
93 });
94
95 if (!result.success) {
96 setLoading(false);
97 if (result.error && isOAuthSessionError(result.error)) {
98 toaster({
99 content: <OAuthErrorMessage error={result.error} />,
100 type: "error",
101 });
102 } else {
103 toaster({
104 content: "Failed to update theme",
105 type: "error",
106 });
107 }
108 return result;
109 }
110
111 mutate((pub) => {
112 if (result.publication && pub?.publication)
113 return {
114 ...pub,
115 publication: { ...pub.publication, ...result.publication },
116 };
117 return pub;
118 }, false);
119 resetChanges();
120 setLoading(false);
121 return result;
122 };
123
124 return {
125 openPicker,
126 setOpenPicker,
127 pub,
128 record,
129 mutate,
130 showPageBackground,
131 setShowPageBackground,
132 localPubTheme,
133 setTheme,
134 changes,
135 resetChanges,
136 image,
137 setImage,
138 pageWidth,
139 setPageWidth,
140 headingFont,
141 setHeadingFont,
142 bodyFont,
143 setBodyFont,
144 pubBGImage,
145 leafletBGRepeat,
146 toaster,
147 submitTheme,
148 };
149}
150
151export type PubThemeEditorState = ReturnType<typeof usePubThemeEditorState>;
152
153export function PubThemePickerPanel(props: { state: PubThemeEditorState }) {
154 let {
155 openPicker,
156 setOpenPicker,
157 showPageBackground,
158 setShowPageBackground,
159 localPubTheme,
160 setTheme,
161 image,
162 setImage,
163 pageWidth,
164 setPageWidth,
165 headingFont,
166 setHeadingFont,
167 bodyFont,
168 setBodyFont,
169 pubBGImage,
170 leafletBGRepeat,
171 } = props.state;
172
173 return (
174 <div className="themeSetterContent flex flex-col w-full">
175 <div className="themeBGLeaflet flex flex-col">
176 <div
177 className={`themeBgPicker flex flex-col gap-0 -mb-[6px] z-10 w-full `}
178 >
179 <PresetThemePicker state={props.state} />
180 <div className="bgPickerBody w-full flex flex-col gap-2 p-2 border border-[#CCCCCC] rounded-md text-[#595959] bg-white">
181 <PubPageWidthSetter
182 pageWidth={pageWidth}
183 setPageWidth={setPageWidth}
184 thisPicker="page-width"
185 openPicker={openPicker}
186 setOpenPicker={setOpenPicker}
187 />
188 <hr className="border-[#CCCCCC] my-0.5" />
189 <BackgroundPicker
190 bgImage={image}
191 setBgImage={setImage}
192 backgroundColor={localPubTheme.bgLeaflet}
193 pageBackground={localPubTheme.bgPage}
194 setPageBackground={(color) => {
195 setTheme((t) => ({ ...t, bgPage: color }));
196 }}
197 setBackgroundColor={(color) => {
198 setTheme((t) => ({ ...t, bgLeaflet: color }));
199 }}
200 openPicker={openPicker}
201 setOpenPicker={setOpenPicker}
202 hasPageBackground={!!showPageBackground}
203 setHasPageBackground={setShowPageBackground}
204 />
205 </div>
206
207 <SectionArrow
208 fill="white"
209 stroke="#CCCCCC"
210 className="ml-2 -mt-[1px]"
211 />
212 </div>
213 </div>
214
215 <div
216 style={{
217 backgroundImage: pubBGImage ? `url(${pubBGImage})` : undefined,
218 backgroundRepeat: leafletBGRepeat ? "repeat" : "no-repeat",
219 backgroundPosition: "center",
220 backgroundSize: !leafletBGRepeat
221 ? "cover"
222 : `calc(${leafletBGRepeat}px / 2 )`,
223 }}
224 className={` relative bg-bg-leaflet px-3 py-4 flex flex-col rounded-md border border-border `}
225 >
226 <div className={`flex flex-col gap-3 z-10`}>
227 <PagePickers
228 pageBackground={localPubTheme.bgPage}
229 primary={localPubTheme.primary}
230 setPageBackground={(color) => {
231 setTheme((t) => ({ ...t, bgPage: color }));
232 }}
233 setPrimary={(color) => {
234 setTheme((t) => ({ ...t, primary: color }));
235 }}
236 openPicker={openPicker}
237 setOpenPicker={(pickers) => setOpenPicker(pickers)}
238 hasPageBackground={showPageBackground}
239 />
240 <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">
241 <FontPicker
242 label="Heading"
243 value={headingFont}
244 onChange={setHeadingFont}
245 />
246 <FontPicker label="Body" value={bodyFont} onChange={setBodyFont} />
247 </div>
248 <PubAccentPickers
249 accent1={localPubTheme.accent1}
250 setAccent1={(color) => {
251 setTheme((t) => ({ ...t, accent1: color }));
252 }}
253 accent2={localPubTheme.accent2}
254 setAccent2={(color) => {
255 setTheme((t) => ({ ...t, accent2: color }));
256 }}
257 openPicker={openPicker}
258 setOpenPicker={(pickers) => setOpenPicker(pickers)}
259 />
260 </div>
261 </div>
262 </div>
263 );
264}
265
266export const PubThemeSetter = (props: {
267 backToMenu: () => void;
268 loading: boolean;
269 setLoading: (l: boolean) => void;
270}) => {
271 let [sample, setSample] = useState<"pub" | "post">("pub");
272 let state = usePubThemeEditorState();
273 let {
274 localPubTheme,
275 headingFont,
276 bodyFont,
277 image,
278 pageWidth,
279 pubBGImage,
280 leafletBGRepeat,
281 pub,
282 record,
283 showPageBackground,
284 submitTheme,
285 } = state;
286
287 return (
288 <CardBorderHiddenContext.Provider value={!showPageBackground}>
289 <BaseThemeProvider
290 local
291 {...localPubTheme}
292 headingFontId={headingFont}
293 bodyFontId={bodyFont}
294 hasBackgroundImage={!!image}
295 className="min-h-0!"
296 >
297 <div className="min-h-0 flex-1 flex flex-col pb-0.5">
298 <div className="flex-shrink-0">
299 <button type="button" onClick={props.backToMenu}>
300 <GoToArrow />
301 </button>
302 </div>
303
304 <div className="themeSetterContent flex flex-col w-full overflow-y-scroll min-h-0 -mb-2 pt-2 ">
305 <PubThemePickerPanel state={state} />
306 <div className="flex flex-col mt-4 ">
307 <div className="flex gap-2 items-center text-sm text-[#8C8C8C]">
308 <div className="text-sm">Preview</div>
309 <Separator classname="h-4!" />{" "}
310 <button
311 type="button"
312 className={`${sample === "pub" ? "font-bold text-[#595959]" : ""}`}
313 onClick={() => setSample("pub")}
314 >
315 Pub
316 </button>
317 <button
318 type="button"
319 className={`${sample === "post" ? "font-bold text-[#595959]" : ""}`}
320 onClick={() => setSample("post")}
321 >
322 Post
323 </button>
324 </div>
325 </div>
326 <div className="pt-2">
327 <ButtonPrimary
328 fullWidth
329 disabled={props.loading}
330 onClick={async () => {
331 await submitTheme(props.setLoading);
332 }}
333 >
334 {props.loading ? "Saving..." : "Save Theme"}
335 </ButtonPrimary>
336 </div>
337 </div>
338 </div>
339 </BaseThemeProvider>
340 </CardBorderHiddenContext.Provider>
341 );
342};