返回 presentation-ai
FontsSection.tsx
1 "use client";
2
3 import { useQueryClient } from "@tanstack/react-query";
4 import { Check, Plus } from "lucide-react";
5 import { useMemo, useState } from "react";
6 import { useForm, useWatch } from "react-hook-form";
7
8 import { createFontPair } from "@/app/_actions/presentation/font-pair-actions";
9 import { CustomFontCreator } from "@/components/notebook/presentation/components/theme/create-theme/font-step/CustomFontCreator";
10 import { useFontUpload } from "@/components/notebook/presentation/components/theme/create-theme/font-step/useFontUpload";
11 import { UserFontPairs } from "@/components/notebook/presentation/components/theme/create-theme/font-step/UserFontPairs";
12 import { type ThemeFormValues } from "@/components/notebook/presentation/components/theme/types";
13 import {
14 Dialog,
15 DialogContent,
16 DialogHeader,
17 DialogTitle,
18 DialogTrigger,
19 } from "@/components/plate/ui/dialog";
20 import { FontLoader } from "@/components/plate/utils/font-loader";
21 import { Button } from "@/components/ui/button";
22 import { cn } from "@/lib/utils";
23 import { useCommonValues } from "../hooks/useCommonValues";
24 import { useUpdateAllSlides } from "../hooks/useUpdateAllSlides";
25
26 export const fontPairs = [
27 { heading: "Inter", body: "Inter" },
28 { heading: "Space Grotesk", body: "Work Sans" },
29 { heading: "IBM Plex Serif", body: "IBM Plex Serif" },
30 { heading: "Lato", body: "Lato" },
31 { heading: "Lexend", body: "Inter" },
32 { heading: "Playfair Display", body: "DM Sans" },
33 { heading: "Big Shoulders Display", body: "Inter" },
34 { heading: "Merriweather", body: "Open Sans" },
35 { heading: "Merriweather", body: "Lexend" },
36 { heading: "Manrope", body: "Manrope" },
37 { heading: "Montserrat", body: "Manrope" },
38 ];
39
40 interface CreateFontPairFormProps {
41 onClose: () => void;
42 onApply: (fonts: string[]) => void;
43 }
44
45 function CreateFontPairForm({ onClose, onApply }: CreateFontPairFormProps) {
46 const updateAllSlides = useUpdateAllSlides();
47 const queryClient = useQueryClient();
48 const [isSaving, setIsSaving] = useState(false);
49
50 const form = useForm<ThemeFormValues>({
51 defaultValues: {
52 isPublic: false,
53 themeBase: "blank",
54 fonts: {
55 heading: "Inter",
56 body: "Inter",
57 headingWeight: 700,
58 bodyWeight: 400,
59 },
60 },
61 });
62
63 const { control, setValue } = form;
64
65 const {
66 isUploadingHeading,
67 isUploadingBody,
68 handleFontUpload,
69 getLocalCustomFonts,
70 } = useFontUpload({ setValue, control });
71
72 const currentHeadingFont = useWatch({ control, name: "fonts.heading" });
73 const currentBodyFont = useWatch({ control, name: "fonts.body" });
74 const currentHeadingUrl = useWatch({ control, name: "fonts.headingUrl" });
75 const currentBodyUrl = useWatch({ control, name: "fonts.bodyUrl" });
76 const currentHeadingWeight = useWatch({
77 control,
78 name: "fonts.headingWeight",
79 });
80 const currentBodyWeight = useWatch({ control, name: "fonts.bodyWeight" });
81
82 const handleSave = async () => {
83 if (!currentHeadingFont || !currentBodyFont) return;
84
85 setIsSaving(true);
86 try {
87 const result = await createFontPair({
88 heading: currentHeadingFont,
89 body: currentBodyFont,
90 headingUrl: currentHeadingUrl,
91 bodyUrl: currentBodyUrl,
92 });
93
94 if (result.success) {
95 await queryClient.invalidateQueries({ queryKey: ["userFontPairs"] });
96 updateAllSlides({
97 fontFamily: {
98 heading: currentHeadingFont,
99 body: currentBodyFont,
100 headingUrl: currentHeadingUrl,
101 bodyUrl: currentBodyUrl,
102 headingWeight: currentHeadingWeight,
103 bodyWeight: currentBodyWeight,
104 },
105 });
106 onApply([currentHeadingFont, currentBodyFont]);
107 onClose();
108 }
109 } catch (error) {
110 console.error("Error saving font pair:", error);
111 setIsSaving(false);
112 }
113 };
114
115 return (
116 <CustomFontCreator
117 control={control}
118 setValue={setValue}
119 isUploadingHeading={isUploadingHeading}
120 isUploadingBody={isUploadingBody}
121 isSaving={isSaving}
122 onUploadHeading={() => handleFontUpload("heading")}
123 onUploadBody={() => handleFontUpload("body")}
124 onSave={handleSave}
125 onCancel={onClose}
126 getLocalCustomFonts={getLocalCustomFonts}
127 currentHeadingFont={currentHeadingFont}
128 currentBodyFont={currentBodyFont}
129 />
130 );
131 }
132
133 export function FontsSection() {
134 const updateAllSlides = useUpdateAllSlides();
135 const [fontsToLoad, setFontsToLoad] = useState<string[]>([]);
136 const { getMostCommonValue } = useCommonValues();
137 const currentFontFamily = getMostCommonValue<{
138 heading?: string;
139 body?: string;
140 }>("fontFamily", { heading: undefined, body: undefined });
141 const [open, setOpen] = useState(false);
142
143 const filteredPairs = useMemo(() => {
144 const curHeading = currentFontFamily?.heading ?? "";
145 const curBody = currentFontFamily?.body ?? "";
146 return fontPairs.filter(
147 (p) => !(p.heading === curHeading && p.body === curBody),
148 );
149 }, [fontPairs, currentFontFamily]);
150
151 return (
152 <div className="h-max space-y-4">
153 <div className="space-y-3">
154 <label className="text-xs font-semibold text-muted-foreground uppercase">
155 Current Font
156 </label>
157 <div className="flex items-center justify-between rounded-lg border p-3">
158 <div className="space-y-1">
159 <div className="text-sm font-semibold text-foreground">
160 {currentFontFamily?.heading || "—"}
161 </div>
162 <div className="text-xs text-muted-foreground">
163 {currentFontFamily?.body || "—"}
164 </div>
165 </div>
166 <Dialog open={open} onOpenChange={setOpen}>
167 <DialogTrigger asChild>
168 <Button size="sm">
169 <Plus className="mr-2 h-4 w-4" /> Create New Pair
170 </Button>
171 </DialogTrigger>
172 <DialogContent>
173 <DialogHeader>
174 <DialogTitle>Create new font pair</DialogTitle>
175 </DialogHeader>
176 <CreateFontPairForm
177 onClose={() => setOpen(false)}
178 onApply={(fonts) => setFontsToLoad(fonts.filter(Boolean))}
179 />
180 </DialogContent>
181 </Dialog>
182 </div>
183 </div>
184
185 <UserFontPairs
186 currentHeading={currentFontFamily?.heading}
187 currentBody={currentFontFamily?.body}
188 onSelect={(
189 heading,
190 body,
191 headingUrl,
192 bodyUrl,
193 headingWeight,
194 bodyWeight,
195 ) => {
196 updateAllSlides({
197 fontFamily: {
198 heading,
199 body,
200 headingUrl,
201 bodyUrl,
202 headingWeight,
203 bodyWeight,
204 },
205 });
206 setFontsToLoad([heading, body].filter(Boolean));
207 }}
208 />
209
210 <div className="space-y-3">
211 <label className="text-xs font-semibold text-muted-foreground uppercase">
212 Font Combinations
213 </label>
214 <div className="space-y-2">
215 {filteredPairs.map((combo) => {
216 const isSelected =
217 (currentFontFamily?.heading ?? "") === combo.heading &&
218 (currentFontFamily?.body ?? "") === combo.body;
219
220 return (
221 <button
222 key={`${combo.heading}-${combo.body}`}
223 className={cn(
224 "flex w-full items-center justify-between rounded-lg border p-3 text-left transition-all hover:bg-accent/50",
225 isSelected
226 ? "border-primary bg-primary/5"
227 : "border-border hover:border-border/80",
228 )}
229 onClick={() => {
230 updateAllSlides({
231 fontFamily: {
232 heading: combo.heading,
233 body: combo.body,
234 },
235 });
236 setFontsToLoad([combo.heading, combo.body]);
237 }}
238 >
239 <div className="space-y-1">
240 <div className="text-sm font-semibold text-foreground">
241 {combo.heading}
242 </div>
243 <div className="text-xs text-muted-foreground">
244 {combo.body}
245 </div>
246 </div>
247 {isSelected && (
248 <Check className="h-4 w-4 shrink-0 text-primary" />
249 )}
250 </button>
251 );
252 })}
253 </div>
254 </div>
255
256 <div style={{ display: "none" }}>
257 <FontLoader fontsToLoad={fontsToLoad} />
258 </div>
259 </div>
260 );
261 }
262
262 lines Plain Text