返回 presentation-ai
ThemeCard.tsx
1 "use client";
2
3 import { useQueryClient } from "@tanstack/react-query";
4 import { Settings2, Star } from "lucide-react";
5 import type React from "react";
6 import { useEffect, useState, useTransition, type KeyboardEvent } from "react";
7
8 import { toggleFavoriteTheme } from "@/app/_actions/presentation/theme-favorite-actions";
9 import { toggleLikeTheme } from "@/app/_actions/presentation/theme-like-actions";
10 import { isBuiltInPresentationTheme } from "@/lib/presentation/theme-resolution";
11 import { type ThemeProperties } from "@/lib/presentation/themes";
12 import { cn } from "@/lib/utils";
13 import { usePresentationState } from "@/states/presentation-state";
14 import { openThemeCustomizer } from "./customize-theme";
15 import { useThemePanelState } from "./theme-panel-state";
16
17 interface ThemeCardProps {
18 themeId: string;
19 theme: ThemeProperties;
20 isSelected: boolean;
21 isFavorite?: boolean;
22 likeCount?: number;
23 isLiked?: boolean;
24 showLikeButton?: boolean;
25 showFavoriteButton?: boolean;
26 showEllipsis?: boolean;
27 showInfo?: boolean;
28 personalizeLabel?: string;
29 onSelect?: (id: string) => void;
30 isFocused?: boolean;
31 isOwner?: boolean;
32 isPublic?: boolean;
33 isAdminTheme?: boolean;
34 canEditSystemTheme?: boolean;
35 refCallback?: (node: HTMLDivElement | null) => void;
36 tabIndex?: number;
37 onFocus?: () => void;
38 onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
39 onKeyUp?: (event: KeyboardEvent<HTMLDivElement>) => void;
40 }
41
42 export function ThemeCard({
43 themeId,
44 theme,
45 isSelected,
46 isFavorite,
47 likeCount = 0,
48 isLiked = false,
49 showLikeButton = false,
50 showFavoriteButton = true,
51 showEllipsis = true,
52 showInfo = true,
53 personalizeLabel,
54 onSelect,
55 isFocused = false,
56 isOwner = false,
57 isPublic = false,
58 isAdminTheme = false,
59 canEditSystemTheme = false,
60 refCallback,
61 tabIndex = 0,
62 onFocus,
63 onKeyDown,
64 onKeyUp,
65 }: ThemeCardProps) {
66 const {
67 openEditMenu,
68 setOpenEditMenu,
69 setEditingTheme,
70 setOpenCreateThemeModal,
71 setIsCustomizing,
72 } = useThemePanelState();
73 const identifier = themeId;
74 const showEditMenu = openEditMenu === identifier;
75 const [isPendingFavorite, startFavoriteTransition] = useTransition();
76 const [isPendingLike, startLikeTransition] = useTransition();
77 const [localIsFavorite, setLocalIsFavorite] = useState(isFavorite ?? false);
78 const [localLikeCount, setLocalLikeCount] = useState(likeCount);
79 const [localIsLiked, setLocalIsLiked] = useState(isLiked);
80
81 const queryClient = useQueryClient();
82 const invalidateFavorites = () => {
83 queryClient.invalidateQueries({
84 queryKey: ["presentation", "themes", "favorites"],
85 });
86 queryClient.invalidateQueries({
87 queryKey: ["presentation", "themes", "public"],
88 });
89 };
90
91 const invalidateLikes = () => {
92 queryClient.invalidateQueries({
93 queryKey: ["presentation", "themes", "public"],
94 });
95 queryClient.invalidateQueries({
96 queryKey: ["presentation", "themes", "favorites"],
97 });
98 };
99
100 useEffect(() => {
101 setLocalIsFavorite(isFavorite ?? false);
102 }, [isFavorite]);
103
104 useEffect(() => {
105 setLocalLikeCount(likeCount);
106 }, [likeCount]);
107
108 useEffect(() => {
109 setLocalIsLiked(isLiked);
110 }, [isLiked]);
111
112 // Font pairing display
113 const fontPairing = `${theme.fonts.heading} / ${theme.fonts.body}`;
114
115 const handleToggleFavorite = (e: React.MouseEvent) => {
116 e.stopPropagation();
117
118 if (!themeId) {
119 return;
120 }
121
122 startFavoriteTransition(async () => {
123 // Optimistic update
124 const previous = localIsFavorite;
125 const optimistic = !previous;
126 setLocalIsFavorite(optimistic);
127
128 try {
129 const result = await toggleFavoriteTheme(themeId);
130 if (result.success) {
131 // Align with server response if it differs
132 if (result.isFavorite !== undefined) {
133 setLocalIsFavorite(result.isFavorite);
134 }
135 invalidateFavorites();
136 } else {
137 // Roll back on failure
138 setLocalIsFavorite(previous);
139 }
140 } catch {
141 setLocalIsFavorite(previous);
142 }
143 });
144 };
145
146 const handleToggleLike = (e: React.MouseEvent) => {
147 e.stopPropagation();
148
149 if (!themeId) {
150 return;
151 }
152
153 startLikeTransition(async () => {
154 // Optimistic update: flip like and adjust count
155 const prevLiked = localIsLiked;
156 const prevCount = localLikeCount;
157 const optimisticLiked = !prevLiked;
158 const optimisticCount = prevCount + (optimisticLiked ? 1 : -1);
159
160 setLocalIsLiked(optimisticLiked);
161 setLocalLikeCount(Math.max(0, optimisticCount));
162
163 try {
164 const result = await toggleLikeTheme(themeId);
165 if (result.success) {
166 // Align to server if different
167 if (typeof result.isLiked === "boolean") {
168 setLocalIsLiked(result.isLiked);
169 }
170 if (typeof result.likeCount === "number") {
171 setLocalLikeCount(result.likeCount);
172 }
173 invalidateLikes?.();
174 } else {
175 // Roll back on failure
176 setLocalIsLiked(prevLiked);
177 setLocalLikeCount(prevCount);
178 }
179 } catch {
180 setLocalIsLiked(prevLiked);
181 setLocalLikeCount(prevCount);
182 }
183 });
184 };
185
186 const handleEditMenuToggle = (e: React.MouseEvent) => {
187 e.stopPropagation();
188 setOpenEditMenu(showEditMenu ? null : identifier);
189 };
190
191 // Only show ellipsis if user is owner
192 const canEdit = isOwner || (isAdminTheme && canEditSystemTheme);
193 // Only show delete if user is owner AND theme is private
194 const canDelete = isOwner && !isPublic;
195 const shouldShowPersonalizeButton = isSelected && personalizeLabel;
196
197 // Use local state for display
198 const isVisuallyActive = isSelected || isFocused;
199
200 const handlePersonalizeClick = (e: React.MouseEvent) => {
201 e.stopPropagation();
202
203 if (isOwner && !isAdminTheme) {
204 setEditingTheme({
205 id: themeId,
206 name: theme.name,
207 description: theme.description ?? null,
208 themeData: theme,
209 isPublic,
210 isAdmin: false,
211 logoUrl: null,
212 userId: "",
213 });
214 setIsCustomizing(false);
215 setOpenCreateThemeModal(true);
216 setOpenEditMenu(null);
217 return;
218 }
219
220 openThemeCustomizer();
221 };
222
223 return (
224 <div
225 className={cn(
226 "relative size-full min-w-0 overflow-hidden rounded-lg border-2 transition-all",
227 isVisuallyActive
228 ? "border-primary shadow-[0_0_0_1px_hsl(var(--primary)/0.35),0_10px_24px_hsl(var(--primary)/0.14)]"
229 : "border-border/70 hover:border-primary/50",
230 )}
231 >
232 {/* Action buttons in top right */}
233 <div className="absolute top-2 right-2 z-10 flex items-center gap-1">
234 {themeId && showFavoriteButton && (
235 <button
236 type="button"
237 onClick={handleToggleFavorite}
238 disabled={isPendingFavorite}
239 className="rounded-full bg-background/80 p-1 transition-colors hover:bg-background disabled:opacity-50"
240 >
241 <Star
242 className={cn(
243 "size-3.5",
244 localIsFavorite
245 ? "fill-yellow-500 text-yellow-500"
246 : "text-muted-foreground",
247 )}
248 />
249 </button>
250 )}
251
252 {showEllipsis && canEdit && (
253 <div className="relative">
254 <button
255 aria-label="theme card control"
256 type="button"
257 onClick={handleEditMenuToggle}
258 className="rounded-full bg-background/80 p-1 transition-colors hover:bg-background"
259 >
260 <svg
261 className="size-3.5 text-muted-foreground"
262 fill="none"
263 viewBox="0 0 24 24"
264 stroke="currentColor"
265 >
266 <path
267 strokeLinecap="round"
268 strokeLinejoin="round"
269 strokeWidth={2}
270 d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
271 />
272 </svg>
273 </button>
274 {showEditMenu && (
275 <div className="absolute top-full right-0 z-20 mt-1 w-28 rounded-lg border border-border bg-background py-1 shadow-lg">
276 <button
277 type="button"
278 onClick={(e) => {
279 e.stopPropagation();
280 setEditingTheme({
281 id: themeId,
282 name: theme.name,
283 description: theme.description ?? null,
284 themeData: theme,
285 isPublic: isPublic,
286 isAdmin: isAdminTheme,
287 logoUrl: null,
288 userId: "",
289 });
290 setOpenCreateThemeModal(true);
291 setOpenEditMenu(null);
292 }}
293 className="w-full px-3 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-muted"
294 >
295 Edit Theme
296 </button>
297 {canDelete && (
298 <button
299 type="button"
300 onClick={(e) => {
301 e.stopPropagation();
302 }}
303 className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-muted"
304 >
305 Delete
306 </button>
307 )}
308 </div>
309 )}
310 </div>
311 )}
312 </div>
313
314 <div
315 ref={refCallback}
316 role="button"
317 aria-pressed={isSelected}
318 data-panel-arrow-target="true"
319 tabIndex={tabIndex}
320 onClick={() => {
321 if (onSelect) {
322 onSelect(themeId);
323 return;
324 }
325 usePresentationState
326 .getState()
327 .setTheme(
328 themeId,
329 isBuiltInPresentationTheme(themeId) ? undefined : theme,
330 );
331 }}
332 onFocus={onFocus}
333 onKeyDown={onKeyDown}
334 onKeyUp={onKeyUp}
335 className={cn(
336 "size-full rounded-md transition-all hover:shadow-lg focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
337 isVisuallyActive ? "bg-primary/5" : "hover:bg-primary/5",
338 )}
339 >
340 {/* Like button for public themes */}
341 {showLikeButton && themeId && (
342 <button
343 type="button"
344 onClick={handleToggleLike}
345 disabled={isPendingLike}
346 className={cn(
347 "absolute top-2 left-2 z-10 flex items-center gap-1 rounded-full bg-background/90 px-2 py-1 backdrop-blur-sm transition-colors hover:bg-background/95 disabled:opacity-50",
348 localIsLiked && "bg-red-50 dark:bg-red-950/30",
349 )}
350 >
351 <svg
352 className={cn(
353 "size-3",
354 localIsLiked
355 ? "fill-red-500 text-red-500"
356 : "fill-transparent stroke-red-500 text-red-500",
357 )}
358 viewBox="0 0 24 24"
359 >
360 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
361 </svg>
362 <span className="text-xs text-foreground">{localLikeCount}</span>
363 </button>
364 )}
365
366 {/* Theme preview */}
367 <div className="relative h-28 w-full overflow-hidden rounded-t-lg sm:h-32">
368 {/* Page background - uses theme.background.override */}
369 <div
370 className="absolute inset-0 p-2.5 sm:p-3"
371 style={{
372 background: theme.colors.primary,
373 // background: theme.background?.override || theme.colors.background ,
374 }}
375 >
376 {/* Slide background - uses theme.colors.background */}
377 <div
378 className="flex size-full flex-col items-center justify-center p-3"
379 style={{
380 backgroundColor: theme.colors.background,
381 borderRadius: theme.borderRadius.card,
382 boxShadow: theme.shadows.card,
383 border: `1px solid ${theme.colors.primary}60`,
384 }}
385 >
386 {/* Title with heading color */}
387 <h3
388 className="mb-1 text-center text-sm font-bold sm:text-base"
389 style={{ color: theme.colors.heading }}
390 >
391 Title
392 </h3>
393 {/* Body text with text color */}
394 <p
395 className="mb-2 text-center text-[11px] sm:text-xs"
396 style={{ color: theme.colors.text }}
397 >
398 Body text{" "}
399 <span
400 className="underline"
401 style={{ color: theme.colors.accent }}
402 >
403 Link
404 </span>
405 </p>
406 {/* Smart layout color bar */}
407 <div
408 className="h-1.5 w-12 rounded-full"
409 style={{ backgroundColor: theme.colors.smartLayout }}
410 title="Smart Layout"
411 />
412 </div>
413 {shouldShowPersonalizeButton && (
414 <button
415 onClick={handlePersonalizeClick}
416 className="absolute right-0.5 bottom-0.5 z-10 flex h-7 items-center gap-1.5 rounded-full bg-purple-600 px-2.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-purple-700"
417 type="button"
418 >
419 <Settings2 className="size-3" />
420 <span>{personalizeLabel}</span>
421 </button>
422 )}
423 </div>
424 </div>
425
426 {/* Theme info */}
427 {showInfo && (
428 <div className="mt-2 flex items-center justify-between gap-2 px-2 pb-2">
429 <div className="flex min-w-0 flex-col items-start gap-0.5">
430 <span className="text-left text-xs font-medium text-foreground">
431 {theme.name}
432 </span>
433 <span className="line-clamp-1 text-left text-xs text-muted-foreground">
434 {fontPairing.trim()}
435 </span>
436 </div>
437 {isSelected && (
438 <div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-purple-600">
439 <svg
440 className="size-3 text-white"
441 fill="none"
442 viewBox="0 0 24 24"
443 stroke="currentColor"
444 >
445 <path
446 strokeLinecap="round"
447 strokeLinejoin="round"
448 strokeWidth={2}
449 d="M5 13l4 4L19 7"
450 />
451 </svg>
452 </div>
453 )}
454 </div>
455 )}
456 </div>
457 </div>
458 );
459 }
460
460 lines Plain Text