返回 presentation-ai
ThemeSelector.tsx
根目录 / src / components / presentation / theme / ThemeSelector.tsx
1 "use client";
2 import {
3 getPublicCustomThemes,
4 getUserCustomThemes,
5 } from "@/app/_actions/presentation/theme-actions";
6 import { CreateThemeModal } from "@/components/notebook/presentation/components/theme/create-theme/CreateThemeModal";
7 import { Button } from "@/components/ui/button";
8 import {
9 Sheet,
10 SheetContent,
11 SheetDescription,
12 SheetHeader,
13 SheetTitle,
14 SheetTrigger,
15 } from "@/components/ui/sheet";
16 import { Skeleton } from "@/components/ui/skeleton";
17 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
18 import {
19 themes,
20 type ThemeProperties,
21 type Themes,
22 } from "@/lib/presentation/themes";
23 import { cn } from "@/lib/utils";
24 import { usePresentationState } from "@/states/presentation-state";
25 import { useQuery } from "@tanstack/react-query";
26 import { Palette } from "lucide-react";
27 import { useState } from "react";
28 import { useThemePanelState } from "../edit-panel/sections/theme/theme-panel-state";
29 import { usePresentationTheme } from "../providers/PresentationThemeProvider";
30
31 interface CustomTheme {
32 id: string;
33 name: string;
34 description: string | null;
35 themeData: ThemeProperties;
36 isPublic: boolean;
37 logoUrl: string | null;
38 userId: string;
39 user?: {
40 name: string | null;
41 };
42 }
43
44 type ThemeId = Themes | `custom-${string}`;
45
46 function ThemeCardSkeleton() {
47 return (
48 <div className="space-y-2 rounded-lg border p-4">
49 <Skeleton className="h-6 w-32" />
50 <Skeleton className="h-4 w-48" />
51 <div className="flex gap-2">
52 <Skeleton className="h-4 w-4 rounded-full" />
53 <Skeleton className="h-4 w-4 rounded-full" />
54 <Skeleton className="h-4 w-4 rounded-full" />
55 </div>
56 </div>
57 );
58 }
59
60 export function ThemeSelector() {
61 const { resolvedTheme } = usePresentationTheme();
62 const { theme: presentationTheme, setTheme: setPresentationTheme } =
63 usePresentationState();
64 const { setOpenCreateThemeModal } = useThemePanelState();
65 const [isThemeSheetOpen, setIsThemeSheetOpen] = useState(false);
66 const [activeTab, setActiveTab] = useState("my-themes");
67 const isDark = resolvedTheme === "dark";
68
69 // Fetch user themes with React Query
70 const { data: userThemes = [], isLoading: isLoadingUserThemes } = useQuery({
71 queryKey: ["userThemes"],
72 queryFn: async () => {
73 const result = await getUserCustomThemes();
74 return result.success ? (result.themes as CustomTheme[]) : [];
75 },
76 enabled: isThemeSheetOpen,
77 });
78
79 // Fetch public themes with React Query
80 const { data: publicThemes = [], isLoading: isLoadingPublicThemes } =
81 useQuery({
82 queryKey: ["publicThemes"],
83 queryFn: async () => {
84 const result = await getPublicCustomThemes();
85 return result.success ? (result.themes as CustomTheme[]) : [];
86 },
87 enabled: isThemeSheetOpen,
88 });
89
90 // Handle theme selection
91 const handleThemeSelect = (
92 themeKey: ThemeId,
93 customData?: ThemeProperties | null,
94 ) => {
95 setPresentationTheme(themeKey as Themes, customData);
96 setIsThemeSheetOpen(false);
97 };
98
99 return (
100 <Sheet open={isThemeSheetOpen} onOpenChange={setIsThemeSheetOpen}>
101 <SheetTrigger asChild>
102 <Button
103 variant="ghost"
104 size="sm"
105 className="text-muted-foreground hover:text-foreground"
106 >
107 <Palette className="mr-1 h-4 w-4" />
108 Theme
109 </Button>
110 </SheetTrigger>
111 <SheetContent
112 side="right"
113 overlay={false}
114 className="absolute top-0 bottom-0 w-full max-w-md overflow-y-auto sm:max-w-lg"
115 container={
116 typeof window === "undefined"
117 ? undefined
118 : typeof document !== "undefined"
119 ? (document.querySelector<HTMLElement>(".sheet-container") ??
120 undefined)
121 : undefined
122 }
123 >
124 <SheetHeader className="mb-5">
125 <SheetTitle>Presentation Theme</SheetTitle>
126 <SheetDescription>
127 Choose a theme for your presentation
128 </SheetDescription>
129 <div>
130 <CreateThemeModal></CreateThemeModal>
131 <Button onClick={() => setOpenCreateThemeModal(true)}>
132 Create New Theme
133 </Button>
134 </div>
135 </SheetHeader>
136
137 <Tabs
138 defaultValue="my-themes"
139 value={activeTab}
140 onValueChange={setActiveTab}
141 >
142 <div className="mb-4">
143 <TabsList>
144 <TabsTrigger value="my-themes">My Themes</TabsTrigger>
145 <TabsTrigger value="public-themes">Public Themes</TabsTrigger>
146 </TabsList>
147 </div>
148
149 <TabsContent value="my-themes">
150 {isLoadingUserThemes ? (
151 <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
152 {[1, 2, 3, 4].map((i) => (
153 <ThemeCardSkeleton key={i} />
154 ))}
155 </div>
156 ) : userThemes.length > 0 ? (
157 <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
158 {userThemes.map((theme) => {
159 const themeData = theme.themeData;
160 const modeColors = themeData.colors;
161 const modeShadows = themeData.shadows;
162
163 return (
164 <button
165 key={theme.id}
166 onClick={() =>
167 handleThemeSelect(theme.id as ThemeId, themeData)
168 }
169 className={cn(
170 "group relative space-y-2 rounded-lg border p-4 text-left transition-all",
171 presentationTheme === theme.id
172 ? "border-primary bg-primary/5"
173 : "border-muted hover:border-primary/50 hover:bg-muted/50",
174 )}
175 style={{
176 borderRadius: themeData.borderRadius.button,
177 boxShadow: modeShadows.card,
178 transition: themeData.transitions.default,
179 backgroundColor: isDark
180 ? "rgba(0,0,0,0.3)"
181 : "rgba(255,255,255,0.9)",
182 }}
183 >
184 <div
185 className="font-medium"
186 style={{
187 color: modeColors.heading,
188 fontFamily: themeData.fonts.heading,
189 }}
190 >
191 {theme.name}
192 </div>
193 <div
194 className="text-sm"
195 style={{
196 color: modeColors.text,
197 fontFamily: themeData.fonts.body,
198 }}
199 >
200 {theme.description ?? "Custom theme"}
201 </div>
202 <div className="flex gap-2">
203 {[modeColors.primary, modeColors.accent].map(
204 (color, i) => (
205 <div
206 key={i}
207 className="h-4 w-4 rounded-full ring-1 ring-white/10 ring-inset"
208 style={{ backgroundColor: color }}
209 />
210 ),
211 )}
212 </div>
213 </button>
214 );
215 })}
216 </div>
217 ) : (
218 <div className="flex h-64 flex-col items-center justify-center">
219 <p className="mb-4 text-muted-foreground">
220 You haven&apos;t created any themes yet
221 </p>
222 <Button onClick={() => setOpenCreateThemeModal(true)}>
223 Create Your First Theme
224 </Button>
225 <CreateThemeModal></CreateThemeModal>
226 </div>
227 )}
228 </TabsContent>
229
230 <TabsContent value="public-themes">
231 {isLoadingPublicThemes ? (
232 <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
233 {[1, 2, 3, 4].map((i) => (
234 <ThemeCardSkeleton key={i} />
235 ))}
236 </div>
237 ) : publicThemes.length > 0 ? (
238 <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
239 {publicThemes.map((theme) => {
240 const themeData = theme.themeData;
241 const modeColors = themeData.colors;
242 const modeShadows = themeData.shadows;
243
244 return (
245 <button
246 key={theme.id}
247 onClick={() =>
248 handleThemeSelect(theme.id as ThemeId, themeData)
249 }
250 className={cn(
251 "group relative space-y-2 rounded-lg border p-4 text-left transition-all",
252 presentationTheme === theme.id
253 ? "border-primary bg-primary/5"
254 : "border-muted hover:border-primary/50 hover:bg-muted/50",
255 )}
256 style={{
257 borderRadius: themeData.borderRadius.button,
258 boxShadow: modeShadows.card,
259 transition: themeData.transitions.default,
260 backgroundColor: isDark
261 ? "rgba(0,0,0,0.3)"
262 : "rgba(255,255,255,0.9)",
263 }}
264 >
265 <div
266 className="font-medium"
267 style={{
268 color: modeColors.heading,
269 fontFamily: themeData.fonts.heading,
270 }}
271 >
272 {theme.name}
273 </div>
274 <div
275 className="text-sm"
276 style={{
277 color: modeColors.text,
278 fontFamily: themeData.fonts.body,
279 }}
280 >
281 {theme.description ?? "Custom theme"}
282 </div>
283 <div className="text-xs text-muted-foreground">
284 By {theme.user?.name ?? "Unknown"}
285 </div>
286 <div className="flex gap-2">
287 {[modeColors.primary, modeColors.accent].map(
288 (color, i) => (
289 <div
290 key={i}
291 className="h-4 w-4 rounded-full ring-1 ring-white/10 ring-inset"
292 style={{ backgroundColor: color }}
293 />
294 ),
295 )}
296 </div>
297 </button>
298 );
299 })}
300 </div>
301 ) : (
302 <div className="flex h-64 items-center justify-center">
303 <p className="text-muted-foreground">
304 No public themes available
305 </p>
306 </div>
307 )}
308 </TabsContent>
309 </Tabs>
310
311 <div className="mt-8">
312 <h3 className="mb-4 text-lg font-semibold">Built-in Themes</h3>
313 <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
314 {Object.entries(themes).map(([key, themeOption]) => {
315 const modeColors = themeOption.colors;
316 const modeShadows = themeOption.shadows;
317
318 return (
319 <button
320 key={key}
321 onClick={() => handleThemeSelect(key as ThemeId)}
322 className={cn(
323 "group relative space-y-2 rounded-lg border p-4 text-left transition-all",
324 presentationTheme === key
325 ? "border-primary bg-primary/5"
326 : "border-muted hover:border-primary/50 hover:bg-muted/50",
327 )}
328 style={{
329 borderRadius: themeOption.borderRadius.button,
330 boxShadow: modeShadows.card,
331 transition: themeOption.transitions.default,
332 backgroundColor:
333 presentationTheme === key
334 ? `${modeColors.primary}${isDark ? "15" : "08"}`
335 : isDark
336 ? "rgba(0,0,0,0.3)"
337 : "rgba(255,255,255,0.9)",
338 }}
339 >
340 <div
341 className="font-medium"
342 style={{
343 color: modeColors.heading,
344 fontFamily: themeOption.fonts.heading,
345 }}
346 >
347 {themeOption.name}
348 </div>
349 <div
350 className="text-sm"
351 style={{
352 color: modeColors.text,
353 fontFamily: themeOption.fonts.body,
354 }}
355 >
356 {themeOption.description}
357 </div>
358 <div className="flex gap-2">
359 {[modeColors.primary, modeColors.accent].map((color, i) => (
360 <div
361 key={i}
362 className="h-4 w-4 rounded-full ring-1 ring-white/10 ring-inset"
363 style={{ backgroundColor: color }}
364 />
365 ))}
366 </div>
367 <div
368 className="mt-2 text-xs"
369 style={{ color: modeColors.text, opacity: 0.7 }}
370 >
371 <span className="block">
372 Heading: {themeOption.fonts.heading}
373 </span>
374 <span className="block">
375 Body: {themeOption.fonts.body}
376 </span>
377 </div>
378 </button>
379 );
380 })}
381 </div>
382 </div>
383 </SheetContent>
384 </Sheet>
385 );
386 }
387
387 lines Plain Text