ppt-tool/frontend/app/hooks/useCustomTemplates.ts
Vadym Samoilenko e360983249 Fix 5 post-Phase8 bugs: SSE crash, custom templates, ordering, persistence, JSON display
- outlines.py: Fix ECONNRESET/socket-hang-up — Depends session closes before
  StreamingResponse generator runs; capture presentation data upfront, use
  async_session_maker() inside inner() for the final DB commit (same pattern as Phase 4)
- useCustomTemplates.ts: Filter null-template items in summary map (crashed on
  presentations without a TemplateModel); use item.layout_count instead of hardcoded 0
- TemplateSelection.tsx: Move custom AI templates section above built-in templates
- presentationGeneration.ts + OutlinePage.tsx: Add selectedTemplateId to Redux so
  template selection persists when navigating away and back to /outline; clearOutlines
  also resets selectedTemplateId for new presentation flows
- DocumentPreviewPage.tsx: Detect JSON file content (table decomposition output) and
  convert to markdown table or pretty-printed code block before passing to MarkdownRenderer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 20:25:28 +00:00

521 lines
18 KiB
TypeScript

"use client";
import React from "react";
import { useState, useEffect, useCallback } from "react";
import { compileCustomLayout, CompiledLayout } from "./compileLayout";
import TemplateService from "../(presentation-generator)/services/api/template";
import { isJsonLayoutCode, parseLayoutSchema, ParsedLayout, LayoutSchema } from "./parseLayoutSchema";
import { SlideRenderer } from "../(presentation-generator)/components/SlideRenderer";
// Adapter: convert ParsedLayout (JSON schema) into a CompiledLayout-compatible object
// so existing code that uses CompiledLayout can work with both formats.
function parsedLayoutToCompiled(parsed: ParsedLayout): CompiledLayout {
const schema: LayoutSchema = parsed.schema;
// Create a React component that renders the JSON schema
function JsonSlideComponent({ data }: { data: any }) {
return React.createElement(SlideRenderer, { schema, content: data });
}
JsonSlideComponent.displayName = parsed.layoutName;
return {
component: JsonSlideComponent,
layoutId: parsed.layoutId,
layoutName: parsed.layoutName,
layoutDescription: parsed.layoutDescription,
schema: null,
sampleData: parsed.sampleData,
schemaJSON: null,
};
}
export interface TemplateSummary {
id: string;
name: string;
total_layouts: number;
}
export interface RawLayoutResponse {
template: string;
layout_id: string;
layout_name: string;
layout_code: string;
fonts?: string[];
}
export interface CustomTemplateDetailResponse {
layouts: RawLayoutResponse[];
template: any;
fonts?: string[];
}
// Compiled layout with all metadata
export interface CustomTemplateLayout extends CompiledLayout {
templateId: string;
rawLayoutId: string;
rawLayoutName: string;
layoutCode: string;
fonts?: string[];
}
export interface CustomTemplateDetail {
layouts: CustomTemplateLayout[];
name: string;
description: string;
id: string;
template: any;
fonts?: string[];
}
// Custom templates for the main page
export interface CustomTemplates {
id: string;
name: string;
layoutCount: number;
isCustom: true;
}
// GLOBAL CACHE
const customTemplateDetailsCache = new Map<string, CustomTemplateDetail>();
// GLOBAL IN-FLIGHT PROMISE TRACKER - prevents duplicate API calls for same ID
const inFlightRequests = new Map<string, Promise<CustomTemplateDetail | null>>();
// GLOBAL CACHE: compiled first-slide previews (we only compile the first layout)
const customTemplateFirstSlideCache = new Map<string, CompiledLayout | null>();
// GLOBAL IN-FLIGHT PROMISE TRACKER - prevents duplicate preview calls for same ID
const inFlightFirstSlideRequests = new Map<string, Promise<CompiledLayout | null>>();
function normalizeCustomTemplateId(id: string): string {
if (!id) return id;
return id.startsWith("custom-") ? id.slice("custom-".length) : id;
}
/**
* Fetch + compile ONLY the first layout for a custom template.
* Accepts either a raw presentationId or a "custom-..." id.
* Uses global cache + in-flight request deduplication.
*/
export async function getCustomTemplateFirstSlidePreview(
presentationIdOrCustomId: string
): Promise<CompiledLayout | null> {
const presentationId = normalizeCustomTemplateId(presentationIdOrCustomId);
if (!presentationId) return null;
// Cache first
if (customTemplateFirstSlideCache.has(presentationId)) {
return customTemplateFirstSlideCache.get(presentationId) ?? null;
}
// In-flight dedupe
const existing = inFlightFirstSlideRequests.get(presentationId);
if (existing) return existing;
const fetchPromise = (async (): Promise<CompiledLayout | null> => {
try {
const data: CustomTemplateDetailResponse = await TemplateService.getCustomTemplateDetails(presentationId);
const firstLayout = data?.layouts?.[0];
if (!firstLayout?.layout_code) {
customTemplateFirstSlideCache.set(presentationId, null);
return null;
}
let compiled: CompiledLayout | null = null;
if (isJsonLayoutCode(firstLayout.layout_code)) {
const parsed = parseLayoutSchema(firstLayout.layout_code);
if (parsed) compiled = parsedLayoutToCompiled(parsed);
} else {
compiled = compileCustomLayout(firstLayout.layout_code);
}
customTemplateFirstSlideCache.set(presentationId, compiled);
return compiled;
} catch (err) {
console.error("Error fetching first-slide preview:", err);
// Don't cache errors; allow retry next time.
return null;
} finally {
inFlightFirstSlideRequests.delete(presentationId);
}
})();
inFlightFirstSlideRequests.set(presentationId, fetchPromise);
return fetchPromise;
}
/**
* Standalone async function to fetch and compile custom template details
* Can be called from hooks or regular async functions (like handleSubmit)
* Uses global cache and in-flight request deduplication
*/
export async function getCustomTemplateDetails(
templateId: string,
name: string = "Custom Template",
description: string = "User-created template"
): Promise<CustomTemplateDetail | null> {
if (!templateId) {
return null;
}
// Check cache first
const cachedTemplate = customTemplateDetailsCache.get(templateId);
if (cachedTemplate) {
return cachedTemplate;
}
// Check if there's already an in-flight request for this ID
const existingRequest = inFlightRequests.get(templateId);
if (existingRequest) {
return existingRequest;
}
// Create new request and track it
const fetchPromise = (async (): Promise<CustomTemplateDetail | null> => {
try {
const data: CustomTemplateDetailResponse = await TemplateService.getCustomTemplateDetails(templateId);
// Compile each layout
const compiledLayouts: CustomTemplateLayout[] = [];
for (const layout of data.layouts) {
try {
let compiled: CompiledLayout | null = null;
if (isJsonLayoutCode(layout.layout_code)) {
const parsed = parseLayoutSchema(layout.layout_code);
if (parsed) compiled = parsedLayoutToCompiled(parsed);
} else {
compiled = compileCustomLayout(layout.layout_code);
}
if (compiled) {
compiledLayouts.push({
...compiled,
templateId: layout.template,
rawLayoutId: layout.layout_id,
rawLayoutName: layout.layout_name,
layoutCode: layout.layout_code,
fonts: layout.fonts,
});
} else {
console.warn(`Failed to compile/parse layout: ${layout.layout_name}`);
}
} catch (compileError) {
console.error(`Error compiling ${layout.layout_name}:`, compileError);
}
}
const result: CustomTemplateDetail = {
layouts: compiledLayouts,
name,
description,
id: templateId,
template: data.template ? data.template : null,
fonts: data.fonts
};
// Cache the result
customTemplateDetailsCache.set(templateId, result);
return result;
} catch (err) {
console.error("Error fetching template details:", err);
throw err;
} finally {
// Clean up in-flight tracker
inFlightRequests.delete(templateId);
}
})();
// Track this request
inFlightRequests.set(templateId, fetchPromise);
return fetchPromise;
}
/**
* Hook to fetch custom template summaries
*/
export function useCustomTemplateSummaries() {
const [templates, setTemplates] = useState<CustomTemplates[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchTemplates = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await TemplateService.getCustomTemplateSummaries();
// const mappedTemplates: CustomTemplates[] = data.filter(item => item.total_layouts && item.total_layouts > 0).map((item) => {
// return {
// id: item.id,
// name: item.name || "Custom Template",
// layoutCount: item.total_layouts,
// isCustom: true as const,
// }
// });
const mappedTemplates: CustomTemplates[] = data.presentations
.filter((item: any) => item.template != null)
.map((item: any) => {
return {
id: item.template.id,
name: item.template.name || "Custom Template",
layoutCount: item.layout_count ?? 0,
isCustom: true as const,
}
});
setTemplates(mappedTemplates);
} catch (err) {
console.error("Error fetching custom templates:", err);
setError(err instanceof Error ? err.message : "Unknown error");
setTemplates([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
return { templates, loading, error, refetch: fetchTemplates };
}
/**
* Hook to fetch and compile custom template layouts
* Uses global cache and in-flight request deduplication to prevent duplicate API calls
*/
export function useCustomTemplateDetails(templateDetail: { id: string, name: string, description: string }) {
const [template, setTemplate] = useState<CustomTemplateDetail | null>(() => {
return templateDetail.id ? customTemplateDetailsCache.get(templateDetail.id) ?? null : null;
});
const [fonts, setFonts] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(() => {
return templateDetail.id ? !customTemplateDetailsCache.has(templateDetail.id) : false;
});
const [error, setError] = useState<string | null>(null);
const fetchTemplateDetails = useCallback(async () => {
if (!templateDetail.id) {
return;
}
// Check cache first - instant return if cached
const cachedTemplate = customTemplateDetailsCache.get(templateDetail.id);
if (cachedTemplate) {
setTemplate(cachedTemplate);
setFonts(cachedTemplate?.fonts ?? []);
setLoading(false);
return;
}
// Check if there's already an in-flight request for this ID
const existingRequest = inFlightRequests.get(templateDetail.id);
if (existingRequest) {
// Wait for the existing request instead of making a new one
setLoading(true);
try {
const result = await existingRequest;
if (result) {
setTemplate(result);
setFonts(result?.fonts ?? []);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
return;
}
// Create new request and track it
setLoading(true);
setError(null);
const fetchPromise = (async (): Promise<CustomTemplateDetail | null> => {
try {
const data: CustomTemplateDetailResponse = await TemplateService.getCustomTemplateDetails(templateDetail.id);
// Compile each layout
const compiledLayouts: CustomTemplateLayout[] = [];
for (const layout of data.layouts) {
try {
let compiled: CompiledLayout | null = null;
if (isJsonLayoutCode(layout.layout_code)) {
const parsed = parseLayoutSchema(layout.layout_code);
if (parsed) compiled = parsedLayoutToCompiled(parsed);
} else {
compiled = compileCustomLayout(layout.layout_code);
}
if (compiled) {
compiledLayouts.push({
...compiled,
templateId: layout.template,
rawLayoutId: layout.layout_id,
rawLayoutName: layout.layout_name,
layoutCode: layout.layout_code,
fonts: layout.fonts,
layoutId: compiled?.layoutId ?? "",
});
} else {
console.warn(`Failed to compile/parse layout: ${layout.layout_name}`);
}
} catch (compileError) {
console.error(`Error compiling ${layout.layout_name}:`, compileError);
}
}
const result: CustomTemplateDetail = {
layouts: compiledLayouts,
name: templateDetail.name,
description: templateDetail.description,
id: templateDetail.id,
template: data.template,
fonts: data.fonts
};
// Cache the result
customTemplateDetailsCache.set(templateDetail.id, result);
return result;
} catch (err) {
console.error("Error fetching template details:", err);
throw err;
} finally {
// Clean up in-flight tracker
inFlightRequests.delete(templateDetail.id);
}
})();
// Track this request
inFlightRequests.set(templateDetail.id, fetchPromise);
try {
const result = await fetchPromise;
if (result) {
setTemplate(result);
setFonts(result?.fonts ?? []);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setTemplate(null);
setFonts([]);
} finally {
setLoading(false);
}
}, [templateDetail.id, templateDetail.name, templateDetail.description]);
useEffect(() => {
if (templateDetail.id) {
fetchTemplateDetails();
}
}, [templateDetail.id, fetchTemplateDetails]);
return { template, loading, error, refetch: fetchTemplateDetails, fonts };
}
/**
* Hook to fetch and compile preview layouts for a single template (first 4 layouts)
*/
export function useCustomTemplatePreview(presentationId: string) {
const [previewLayouts, setPreviewLayouts] = useState<CompiledLayout[]>([]);
const [loading, setLoading] = useState(true);
const [totalLayouts, setTotalLayouts] = useState(0);
useEffect(() => {
if (!presentationId) return;
const fetchPreviews = async () => {
try {
setLoading(true);
const data = await TemplateService.getCustomTemplateDetails(presentationId);
setTotalLayouts(data.layouts.length);
// Compile first 4 layouts for preview
const compiled: CompiledLayout[] = [];
const layoutsToPreview = data.layouts.slice(0, 4);
for (const layout of layoutsToPreview) {
try {
if (isJsonLayoutCode(layout.layout_code)) {
const parsed = parseLayoutSchema(layout.layout_code);
if (parsed) {
compiled.push(parsedLayoutToCompiled(parsed));
}
} else {
const result = compileCustomLayout(layout.layout_code);
if (result) {
compiled.push(result);
}
}
} catch (e) {
console.warn(`Failed to compile/parse preview: ${layout.layout_name}`);
}
}
setPreviewLayouts(compiled);
} catch (err) {
console.error("Error fetching preview layouts:", err);
} finally {
setLoading(false);
}
};
fetchPreviews();
}, [presentationId]);
return { previewLayouts, loading: loading, totalLayouts: totalLayouts };
}
/**
* Hook to fetch and compile preview for ONLY the first layout of a custom template.
* Accepts either a raw presentationId or a "custom-..." id.
*/
export function useCustomTemplateFirstSlidePreview(presentationIdOrCustomId: string) {
const [previewLayout, setPreviewLayout] = useState<CompiledLayout | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!presentationIdOrCustomId) return;
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const compiled = await getCustomTemplateFirstSlidePreview(presentationIdOrCustomId);
if (cancelled) return;
setPreviewLayout(compiled);
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Unknown error");
setPreviewLayout(null);
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [presentationIdOrCustomId]);
return { previewLayout, loading, error };
}