返回 presentation-ai
usePresentationData.ts
根目录 / src / hooks / presentation / usePresentationData.ts
1 import {
2 getPresentation,
3 updatePresentation,
4 } from "@/app/_actions/notebook/presentation/presentationActions";
5 import { updatePresentationThumbnailUrl } from "@/app/_actions/presentation/presentation-thumbnail-actions";
6 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
7 import { usePresentationTheme } from "@/components/presentation/providers/PresentationThemeProvider";
8 import {
9 applyPageBackgroundToConfig,
10 buildPresentationCustomization,
11 getPresentationCustomization,
12 } from "@/lib/presentation/customization";
13 import { loadCustomFonts } from "@/lib/presentation/loadCustomFont";
14 import { getPresentationThumbnailUrl } from "@/lib/presentation/thumbnail";
15 import {
16 setThemeVariables,
17 type ThemeProperties,
18 type Themes,
19 themes,
20 } from "@/lib/presentation/themes";
21 import { usePresentationHistoryState } from "@/states/presentation-history-state";
22 import { usePresentationState } from "@/states/presentation-state";
23 import { useQuery } from "@tanstack/react-query";
24 import debounce from "lodash.debounce";
25 import { nanoid } from "nanoid";
26 import { useRouter } from "next/navigation";
27 import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
28 import { toast } from "sonner";
29
30 export function usePresentationData(id: string, forcedReadOnly = false) {
31 const { resolvedTheme } = usePresentationTheme();
32 const setCurrentPresentation = usePresentationState(
33 (s) => s.setCurrentPresentation,
34 );
35 const setPresentationInput = usePresentationState(
36 (s) => s.setPresentationInput,
37 );
38 const currentPresentationId = usePresentationState(
39 (s) => s.currentPresentationId,
40 );
41
42 const setOutline = usePresentationState((s) => s.setOutline);
43 const setSlides = usePresentationState((s) => s.setSlides);
44 const setThumbnailUrl = usePresentationState((s) => s.setThumbnailUrl);
45 const isGeneratingPresentation = usePresentationState(
46 (s) => s.isGeneratingPresentation,
47 );
48 const setTheme = usePresentationState((s) => s.setTheme);
49 const setImageModel = usePresentationState((s) => s.setImageModel);
50 const setImageSource = usePresentationState((s) => s.setImageSource);
51 const setPresentationStyle = usePresentationState(
52 (s) => s.setPresentationStyle,
53 );
54 const setPageStyle = usePresentationState((s) => s.setPageStyle);
55 const setLanguage = usePresentationState((s) => s.setLanguage);
56 const setTextContent = usePresentationState((s) => s.setTextContent);
57 const setTone = usePresentationState((s) => s.setTone);
58 const setAudience = usePresentationState((s) => s.setAudience);
59 const setScenario = usePresentationState((s) => s.setScenario);
60 const slides = usePresentationState((s) => s.slides);
61 const theme = usePresentationState((s) => s.theme);
62 const setCurrentSlideId = usePresentationState((s) => s.setCurrentSlideId);
63 const setIsReadOnly = usePresentationState((s) => s.setIsReadOnly);
64 const clearHistory = usePresentationHistoryState((s) => s.clearHistory);
65 // Track the theme value as it exists in the database to avoid redundant saves on hydration
66 const dbThemeRef = useRef<string | null>(null);
67 const canPersistThumbnailRef = useRef(false);
68
69 // Create a debounced function to update the theme in the database
70 const debouncedThemeUpdate = useCallback(
71 debounce((presentationId: string, newTheme: string) => {
72 const state = usePresentationState.getState();
73 updatePresentation({
74 id: presentationId,
75 theme: newTheme,
76 customization: buildPresentationCustomization({
77 customThemeData: state.customThemeData,
78 pageStyle: state.pageStyle,
79 presentationStyle: state.presentationStyle,
80 generationAspectRatio: state.generationAspectRatio,
81 textContent: state.textContent,
82 tone: state.tone,
83 audience: state.audience,
84 scenario: state.scenario,
85 pageBackground: state.pageBackground,
86 }),
87 })
88 .then((result) => {
89 if (result.success) {
90 // Theme updated in database
91 } else {
92 console.error("Failed to update theme:", result.message);
93 }
94 })
95 .catch((error) => {
96 console.error("Error updating theme:", error);
97 });
98 }, 600),
99 [],
100 );
101 const router = useRouter();
102
103 // Use React Query to fetch presentation data
104 const { data: presentationData, isLoading } = useQuery({
105 queryKey: ["presentation", id],
106 queryFn: async () => {
107 const result = await getPresentation(id);
108 if (!result.success) {
109 toast.error(result.message ?? "Failed to load presentation");
110 router.push("/404");
111 return null;
112 }
113 const canEdit = Boolean(result.canEdit);
114 canPersistThumbnailRef.current = !forcedReadOnly && canEdit;
115 setIsReadOnly(forcedReadOnly || !canEdit);
116 return result.presentation;
117 },
118 // Only fetch if not generating and we don't already have slides
119 enabled:
120 currentPresentationId !== id ||
121 (!isGeneratingPresentation && slides.length === 0),
122 });
123
124 // Update presentation state when data is fetched
125 useLayoutEffect(() => {
126 if (isGeneratingPresentation) {
127 return;
128 }
129 // Don't set data if we already have slides
130 if (slides.length > 0 && currentPresentationId === id) {
131 return;
132 }
133
134 if (presentationData) {
135 const customization = getPresentationCustomization(
136 presentationData.presentation?.customization,
137 );
138 const customizationThemeId = presentationData.presentation?.theme ?? null;
139 // Record the theme as it exists in the DB so initial hydration doesn't trigger a save
140 dbThemeRef.current = customizationThemeId;
141 setCurrentPresentation(presentationData.id, presentationData.title);
142 setPresentationInput(
143 presentationData.presentation?.prompt ?? presentationData.title,
144 );
145
146 // Load all content from the database
147 const presentationContent = presentationData.presentation
148 ?.content as unknown as {
149 slides: PlateSlide[];
150 config: Record<string, unknown>;
151 };
152
153 // Fix duplicate slide IDs (migration for existing presentations with the bug)
154 const rawSlides = presentationContent?.slides ?? [];
155 const seenIds = new Set<string>();
156 let hasDuplicates = false;
157 const fixedSlides = rawSlides.map((slide) => {
158 if (seenIds.has(slide.id)) {
159 // Duplicate ID found, generate a new unique ID
160 hasDuplicates = true;
161 return { ...slide, id: nanoid() };
162 }
163 seenIds.add(slide.id);
164 return slide;
165 });
166
167 // Set slides with fixed IDs
168 setSlides(fixedSlides);
169
170 // Persist the fixed slides to database if we had duplicates
171 if (hasDuplicates) {
172 void updatePresentation({
173 id: presentationData.id,
174 content: {
175 slides: fixedSlides,
176 config: presentationContent?.config ?? {},
177 },
178 });
179 }
180
181 setCurrentSlideId(fixedSlides[0]?.id ?? null);
182 const currentThumb = presentationData.thumbnailUrl;
183 const derivedThumbnailUrl = getPresentationThumbnailUrl(fixedSlides);
184 setThumbnailUrl(currentThumb ?? derivedThumbnailUrl ?? undefined);
185 if (
186 !currentThumb &&
187 derivedThumbnailUrl &&
188 canPersistThumbnailRef.current
189 ) {
190 void (async () => {
191 await updatePresentationThumbnailUrl({
192 id: presentationData.id,
193 thumbnailUrl: derivedThumbnailUrl,
194 onlyIfMissing: true,
195 });
196 })();
197 }
198
199 const { setPageBackground } = usePresentationState.getState();
200 // Priority: customization.pageBackground > content.config (backward compatibility)
201 if (customization?.pageBackground) {
202 const merged = applyPageBackgroundToConfig(
203 customization.pageBackground,
204 (presentationContent?.config as Record<string, unknown>) ?? {},
205 );
206 setPageBackground(merged);
207 } else if (
208 presentationContent?.config?.backgroundOverride !== undefined
209 ) {
210 // Backward compatibility: read from old content.config location
211 setPageBackground(
212 presentationContent.config as Record<string, unknown>,
213 );
214 }
215
216 // Set outline
217 if (presentationData.presentation?.outline) {
218 setOutline(presentationData.presentation.outline);
219 }
220
221 // Set theme if available, or use default based on user's system theme
222 if (customizationThemeId) {
223 const themeId = customizationThemeId;
224 const customThemeData =
225 customization?.themeData as ThemeProperties | undefined;
226
227 if (customThemeData) {
228 setTheme(themeId, customThemeData);
229 } else if (themeId in themes) {
230 setTheme(themeId as Themes);
231 } else {
232 const fallback = resolvedTheme === "dark" ? "ebony" : "mystique";
233 setTheme(fallback);
234 }
235 } else {
236 // No theme set in database, use default based on user's system theme
237 const defaultTheme = resolvedTheme === "dark" ? "ebony" : "mystique";
238 setTheme(defaultTheme);
239 }
240
241 if (presentationData?.presentation?.imageSource) {
242 setImageSource(
243 presentationData.presentation.imageSource as "ai" | "stock",
244 );
245 }
246
247 // Set presentationStyle if available
248 if (customization?.presentationStyle) {
249 setPresentationStyle(customization.presentationStyle);
250 } else if (presentationData?.presentation?.presentationStyle) {
251 setPresentationStyle(presentationData.presentation.presentationStyle);
252 }
253
254 if (customization?.pageStyle) {
255 setPageStyle(customization.pageStyle);
256 }
257
258 if (customization?.textContent) {
259 setTextContent(customization.textContent);
260 }
261 if (customization?.tone) {
262 setTone(customization.tone);
263 }
264 if (customization?.audience) {
265 setAudience(customization.audience);
266 }
267 if (customization?.scenario) {
268 setScenario(customization.scenario);
269 }
270
271 // Set language if available
272 if (presentationData.presentation?.language) {
273 setLanguage(presentationData.presentation.language);
274 }
275
276 clearHistory();
277 }
278 }, [
279 presentationData,
280 isGeneratingPresentation,
281 slides.length, // Add slides.length to dependencies to check if we already have slides
282 setCurrentPresentation,
283 setPresentationInput,
284 setOutline,
285 setSlides,
286 setTheme,
287 setImageModel,
288 setPresentationStyle,
289 setPageStyle,
290 setLanguage,
291 currentPresentationId,
292 id,
293 setImageSource,
294 setThumbnailUrl,
295 setTextContent,
296 setTone,
297 setAudience,
298 setScenario,
299 setIsReadOnly,
300 clearHistory,
301 forcedReadOnly,
302 resolvedTheme,
303 ]);
304
305 // Update theme when it changes (but not on initial hydration)
306 useEffect(() => {
307 if (!id || isLoading || !theme) return;
308 // If we don't yet know the DB theme, skip until hydration sets it
309 if (dbThemeRef.current === null) return;
310 // Skip if the current theme matches the DB state (hydration)
311 if (theme === dbThemeRef.current) return;
312
313 // Persist the new theme and update our DB baseline to prevent repeat writes
314 dbThemeRef.current = theme as string;
315 debouncedThemeUpdate(id, theme as string);
316 }, [theme, id, debouncedThemeUpdate, isLoading]);
317
318 // Set theme variables when theme changes
319 useEffect(() => {
320 if (theme && resolvedTheme) {
321 const state = usePresentationState.getState();
322 // Check if we have custom theme data
323 if (state.customThemeData) {
324 setThemeVariables(state.customThemeData);
325 }
326 // Otherwise try to use a predefined theme
327 else if (typeof theme === "string" && theme in themes) {
328 const currentTheme = themes[theme as keyof typeof themes];
329 if (currentTheme) {
330 setThemeVariables(currentTheme);
331 }
332 }
333 }
334 }, [theme, resolvedTheme]);
335
336 // Load custom fonts when theme changes
337 useEffect(() => {
338 const state = usePresentationState.getState();
339 const themeData = state.customThemeData;
340
341 console.log(themeData);
342 if (themeData?.fonts) {
343 const { heading, body, headingUrl, bodyUrl, headingWeight, bodyWeight } =
344 themeData.fonts;
345
346 // Only load if we have custom font URLs
347 if (headingUrl || bodyUrl) {
348 loadCustomFonts({
349 headingFont: heading,
350 headingUrl,
351 headingWeight,
352 bodyFont: body,
353 bodyUrl,
354 bodyWeight,
355 }).catch((error) => {
356 console.error("Failed to load custom fonts:", error);
357 });
358 }
359 }
360 }, [theme]);
361
362 // Get the current theme data
363 const currentThemeData = (() => {
364 const state = usePresentationState.getState();
365 if (state.customThemeData) {
366 return state.customThemeData;
367 }
368 if (typeof theme === "string" && theme in themes) {
369 return themes[theme as keyof typeof themes];
370 }
371 return null;
372 })();
373
374 return {
375 presentationData,
376 isLoading,
377 currentThemeData,
378 };
379 }
380
380 lines TYPESCRIPT