refactor: replace dynamic slug route with query params

This commit is contained in:
shiva raj badu 2026-03-24 11:46:58 +05:45
parent 287bc6bd69
commit e48a15fad0
No known key found for this signature in database
9 changed files with 32 additions and 473 deletions

View file

@ -20,9 +20,9 @@ export const CustomTemplateCard = React.memo(function CustomTemplateCard({ templ
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
const handleOpen = useCallback(() => {
if (template.id.startsWith('custom-')) {
router.push(`/template-preview/${template.id}`)
router.push(`/template-preview?slug=${template.id}`)
} else {
router.push(`/template-preview/custom-${template.id}`)
router.push(`/template-preview?slug=custom-${template.id}`)
}
}
, [router, template.id]);
@ -46,7 +46,7 @@ export const CustomTemplateCard = React.memo(function CustomTemplateCard({ templ
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-loading-${index}`}
className="relative bg-gradient-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
className="relative bg-linear-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
</div>
@ -172,7 +172,7 @@ const LayoutPreview = () => {
}
}, []);
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview/${id}`), [router]);
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview?slug=${id}`), [router]);
@ -193,7 +193,7 @@ const LayoutPreview = () => {
return (
<div className="min-h-screen relative font-syne">
<div
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
className='fixed z-0 -bottom-[16.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',

View file

@ -38,7 +38,7 @@ export const SaveLayoutModal: React.FC<SaveLayoutModalProps> = ({
const id = await onSave(layoutName.trim(), description.trim());
if (id) {
// Redirect to the new template preview page
router.push(`/template-preview/custom-${id}`);
router.push(`/template-preview?slug=custom-${id}`);
}
// Reset form after navigation decision
setLayoutName("");

View file

@ -47,7 +47,7 @@ const CustomTemplatePage = () => {
trackEvent(MixpanelEvent.CustomTemplate_Save_Templates_API_Call);
const id = await saveLayout(layoutName, description);
if (id) {
router.push(`/template-preview/custom-${id}`);
router.push(`/template-preview?slug=custom-${id}`);
}
return id;
};
@ -94,7 +94,7 @@ const CustomTemplatePage = () => {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="min-h-screen bg-linear-to-br from-slate-50 to-slate-100">
<Header />
<div className="max-w-[1440px] aspect-video mx-auto px-6">
{/* Header */}

View file

@ -36,9 +36,9 @@ const Header = () => {
<span className="text-sm font-medium font-inter">Create Template</span>
</Link>
<Link
href="/template-preview"
href="/templates"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/templates" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>

View file

@ -1,185 +0,0 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { ApiResponseHandler } from '@/app/(presentation-generator)/services/api/api-error-handler';
import { ProcessedSlide } from '@/app/(presentation-generator)/custom-template/types';
import { CustomTemplateLayout } from '@/app/hooks/useCustomTemplates';
import { getApiUrl } from '@/utils/api';
interface LayoutPayload {
layout_id: string;
layout_code: string;
layout_name: string;
}
/** Slide state for template preview: ProcessedSlide plus saved layout code and name */
export type TemplatePreviewSlideState = ProcessedSlide & {
react?: string;
layout_name?: string;
};
interface UseTemplateLayoutsAutoSaveOptions {
templateId: string | null;
layouts: CustomTemplateLayout[];
slideStates: TemplatePreviewSlideState[];
debounceMs?: number;
enabled?: boolean;
}
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
export const useTemplateLayoutsAutoSave = ({
templateId,
layouts,
slideStates,
debounceMs = 2000,
enabled = true,
}: UseTemplateLayoutsAutoSaveOptions) => {
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>('');
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const isSavingRef = useRef<boolean>(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
// Build the payload for saving
const buildPayload = useCallback((): LayoutPayload[] => {
const payload: LayoutPayload[] = [];
layouts.forEach((layout, index) => {
const slideState = slideStates[index];
if (slideState?.react && layout.rawLayoutId) {
payload.push({
layout_id: layout.rawLayoutId,
layout_code: slideState.react,
layout_name: slideState.layout_name || `Slide${index + 1}`
});
}
});
return payload;
}, [layouts, slideStates]);
// Save function
const saveLayouts = useCallback(async (payload: LayoutPayload[]) => {
if (!templateId || payload.length === 0 || isSavingRef.current) {
return false;
}
const currentDataString = JSON.stringify(payload);
// Skip if data hasn't changed since last save
if (currentDataString === lastSavedDataRef.current) {
return false;
}
try {
isSavingRef.current = true;
setSaveStatus('saving');
console.log('🔄 Auto-saving template layouts...');
const response = await fetch(getApiUrl('/api/v1/ppt/template/update'), {
method: 'PUT',
headers: getHeader(),
body: JSON.stringify({
id: templateId,
layouts: payload,
}),
});
await ApiResponseHandler.handleResponse(response, 'Failed to auto-save layouts');
// Update last saved data reference
lastSavedDataRef.current = currentDataString;
setLastSavedAt(new Date());
setSaveStatus('saved');
console.log('✅ Auto-save successful');
// Reset to idle after showing "saved" briefly
setTimeout(() => {
setSaveStatus('idle');
}, 2000);
return true;
} catch (error) {
console.error('❌ Auto-save failed:', error);
setSaveStatus('error');
// Reset to idle after showing error briefly
setTimeout(() => {
setSaveStatus('idle');
}, 3000);
return false;
} finally {
isSavingRef.current = false;
}
}, [templateId]);
// Debounced save trigger
const debouncedSave = useCallback(() => {
if (!enabled || !templateId) return;
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Set new timeout
saveTimeoutRef.current = setTimeout(() => {
const payload = buildPayload();
if (payload.length > 0) {
saveLayouts(payload);
}
}, debounceMs);
}, [enabled, templateId, buildPayload, saveLayouts, debounceMs]);
// Watch for changes in slideStates
useEffect(() => {
if (!enabled || !templateId || slideStates.length === 0) return;
// Check if any slide is still processing
const hasProcessingSlide = Array.from(slideStates.values()).some(
slide => slide.processing
);
if (hasProcessingSlide) return;
debouncedSave();
// Cleanup timeout on unmount or when dependencies change
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [slideStates, enabled, templateId, debouncedSave]);
// Manual save function
const saveNow = useCallback(async () => {
// Clear any pending debounced save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const payload = buildPayload();
return saveLayouts(payload);
}, [buildPayload, saveLayouts]);
// Cleanup on unmount - save any pending changes
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return {
saveStatus,
lastSavedAt,
saveNow,
};
};

View file

@ -1,22 +0,0 @@
import GroupLayoutPreview from './components/TemplatePreviewClient';
// Allow dynamic params for custom templates
export const dynamicParams = true;
// Generate static params for built-in template groups
export async function generateStaticParams() {
// Pre-render built-in template routes at build time
// Custom templates (custom-*) will be generated on-demand
return [
{ slug: 'neo-general' },
{ slug: 'neo-standard' },
{ slug: 'neo-modern' },
{ slug: 'general' },
{ slug: 'modern' },
{ slug: 'standard' },
];
}
export default function GroupLayoutPreviewPage() {
return <GroupLayoutPreview />;
}

View file

@ -1,35 +1,30 @@
"use client";
import React, { useEffect } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Home, Loader2, Trash2 } from "lucide-react";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
import TemplateService from "../../../services/api/template";
import Header from "../../../dashboard/components/Header";
import TemplateService from "../../services/api/template";
import Header from "../../(dashboard)/dashboard/components/Header";
import { toast } from "sonner";
import { CustomTemplateLayout, useCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { templates as templateGroups, getTemplatesByTemplateName } from "@/app/presentation-templates";
const GroupLayoutPreview = () => {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const templateParams = params.slug as string;
const templateParams = searchParams.get("slug") || "";
// Check if this is a custom template
const isCustom = templateParams.startsWith("custom-");
const customTemplateId = isCustom ? templateParams.split("custom-")[1] : null;
// Fetch static templates if not custom
const staticTemplates = !isCustom ? getTemplatesByTemplateName(templateParams) : [];
const staticGroup = !isCustom ? templateGroups.find((g: { id: string }) => g.id === templateParams) : null;
// Fetch custom template details if custom
const {
template: customTemplate,
loading: customLoading,
@ -37,8 +32,6 @@ const GroupLayoutPreview = () => {
fonts: customFonts,
} = useCustomTemplateDetails({ id: templateParams?.split("custom-")[1] || "", name: "", description: "" });
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
@ -60,15 +53,13 @@ const GroupLayoutPreview = () => {
const success = await TemplateService.deleteCustomTemplate(customTemplateId);
if (success.success) {
toast.success("Template deleted successfully");
router.push("/template-preview");
router.push("/templates");
} else {
toast.error("Failed to delete template");
}
};
// Loading state for custom templates
if (isCustom && (customLoading)) {
if (isCustom && customLoading) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
@ -80,7 +71,6 @@ const GroupLayoutPreview = () => {
);
}
// Error state
if (isCustom && customError) {
return (
<div className="min-h-screen bg-gray-50">
@ -88,7 +78,7 @@ const GroupLayoutPreview = () => {
<div className="flex flex-col items-center justify-center py-24">
<h2 className="text-2xl font-bold text-red-600 mb-4">Error loading template</h2>
<p className="text-gray-600 mb-4">{customError}</p>
<Button onClick={() => router.push("/template-preview")}>
<Button onClick={() => router.push("/templates")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Templates
</Button>
@ -97,10 +87,9 @@ const GroupLayoutPreview = () => {
);
}
// Empty state
if (
(!isCustom && (!staticGroup || staticTemplates.length === 0)) ||
(isCustom && (!customTemplate))
(isCustom && !customTemplate)
) {
return (
<div className="min-h-screen bg-gray-50">
@ -109,7 +98,7 @@ const GroupLayoutPreview = () => {
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Template not found
</h2>
<Button onClick={() => router.push("/template-preview")}>
<Button onClick={() => router.push("/templates")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Templates
</Button>
@ -118,7 +107,6 @@ const GroupLayoutPreview = () => {
);
}
// Determine what to render
const templateName = isCustom ? customTemplate?.template.name || "Custom Template" : staticGroup?.name || "";
const templateDescription = isCustom
? customTemplate?.template.description || ""
@ -127,13 +115,10 @@ const GroupLayoutPreview = () => {
? customTemplate?.layouts.length || 0
: staticTemplates.length;
console.log('compileLayout', customTemplate)
return (
<div className="min-h-screen bg-gray-50">
<Header />
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className=" mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-4 max-w-[1440px] mx-auto">
@ -155,7 +140,7 @@ const GroupLayoutPreview = () => {
size="sm"
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_All_Groups_Button_Clicked, { pathname });
router.push("/template-preview");
router.push("/templates");
}}
className="flex items-center gap-2"
>
@ -166,7 +151,6 @@ const GroupLayoutPreview = () => {
{isCustom && (
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
@ -199,12 +183,9 @@ const GroupLayoutPreview = () => {
</p>
</div>
</div>
</header>
{/* Layout Grid - Wrapped in SchemaHighlightProvider for custom templates */}
<main className="mx-auto px-2 py-8" id="presentation-page">
{/* Static Templates */}
{!isCustom && (
<div className="space-y-12 w-[1440px] h-[720px] aspect-video mx-auto">
{staticTemplates.map((template: any, index: number) => {
@ -251,12 +232,8 @@ const GroupLayoutPreview = () => {
</div>
)}
{/* Custom Templates - with page-level schema editor */}
{isCustom && (
<div className="flex flex-col items-center justify-center w-full gap-10 aspect-video mx-auto">
{/* Slides List */}
<div className="flex flex-col items-center justify-center w-full gap-10 aspect-video mx-auto">
{customTemplate && customTemplate.layouts.map((layout: CustomTemplateLayout, index: number) => {
const LayoutComponent = layout.component;
return (
@ -280,7 +257,6 @@ const GroupLayoutPreview = () => {
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded text-sm font-mono">
{templateParams}:{layout.layoutId}
</span>
</div>
</div>
@ -295,8 +271,6 @@ const GroupLayoutPreview = () => {
</Card>
);
})}
</div>
)}
</main>

View file

@ -1,222 +1,14 @@
"use client";
import React, { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
import { ExternalLink, Loader2, Plus } from "lucide-react";
import { templates } from "@/app/presentation-templates";
import type { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { TemplateWithData } from "@/app/presentation-templates/utils";
import {
useCustomTemplateSummaries,
useCustomTemplatePreview,
CustomTemplates,
} from "@/app/hooks/useCustomTemplates";
import { CompiledLayout } from "@/app/hooks/compileLayout";
import Header from "../(dashboard)/dashboard/components/Header";
// Component for rendering custom template card with lazy-loaded previews
const CustomTemplateCard = ({ template }: { template: CustomTemplates }) => {
const router = useRouter();
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(template.id);
const handleNavigate = () => {
if (template.id.startsWith('custom-')) {
router.push(`/template-preview/${template.id}`);
} else {
router.push(`/template-preview/custom-${template.id}`);
}
}
import React, { Suspense } from "react";
import { Loader2 } from "lucide-react";
import GroupLayoutPreview from "./components/TemplatePreviewClient";
const TemplatePreviewPage = () => {
return (
<Card
className="cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden"
onClick={handleNavigate}
>
<div className="p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-bold text-gray-900">
{template.name}
</h3>
<div className="flex items-center gap-2">
<span className="px-2.5 py-0.5 bg-purple-100 text-purple-800 rounded-full text-sm font-medium">
{totalLayouts}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
</div>
</div>
{/* Layout previews */}
<div className="grid grid-cols-2 gap-2">
{loading ? (
// Loading placeholders
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-loading-${index}`}
className="relative bg-gradient-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
</div>
))
) : previewLayouts.length > 0 ? (
// Actual layout previews
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})
) : (
// Empty state placeholders
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-empty-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<span className="text-xs text-gray-400">No preview</span>
</div>
))
)}
</div>
</div>
</Card>
<Suspense fallback={<div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>}>
<GroupLayoutPreview />
</Suspense>
);
};
const LayoutPreview = () => {
const router = useRouter();
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = "https://cdn.tailwindcss.com";
script.async = true;
document.head.appendChild(script);
}
}, []);
const totalStaticLayouts = templates.reduce((acc: number, g: TemplateLayoutsWithSettings) => acc + g.layouts.length, 0);
const totalCustomLayouts = customTemplates.reduce((acc: number, t: CustomTemplates) => acc + t.layoutCount, 0);
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">All Templates</h1>
<p className="text-gray-600 mt-2">
{totalStaticLayouts + totalCustomLayouts} layouts across{" "}
{templates.length + customTemplates.length} templates
</p>
</div>
{/* Inbuilt Templates Section */}
<section className="mb-12">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Inbuilt Templates</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{templates.map((template: TemplateLayoutsWithSettings) => {
const previewLayouts = template.layouts.slice(0, 4);
return (
<Card
key={template.id}
className="cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden"
onClick={() => router.push(`/template-preview/${template.id}`)}
>
<div className="p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-bold text-gray-900 capitalize">
{template.name}
</h3>
<div className="flex items-center gap-2">
<span className="px-2.5 py-0.5 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
{template.layouts.length}
</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{template.description}
</p>
<div className="grid grid-cols-2 gap-2">
{previewLayouts.map((layout: TemplateWithData, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})}
</div>
</div>
</Card>
);
})}
</div>
</section>
{/* Custom Templates Section */}
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800 ">
My Custom Templates
</h2>
<a href="/custom-template" className="text-sm flex font-bold font-inter items-center justify-center gap-2 bg-[#5146E5] text-white px-4 py-2 rounded-md">
<Plus className="w-4 h-4" /> Create new template
</a>
</div>
{customLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : customTemplates.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-2">
Custom templates you create will appear here.
</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{customTemplates.map((template: CustomTemplates) => (
<CustomTemplateCard key={template.id} template={template} />
))}
</div>
)}
</section>
</div>
</div>
);
};
export default LayoutPreview;
export default TemplatePreviewPage;

View file

@ -6,7 +6,7 @@ import { Layout, Plus } from "lucide-react";
const Header: React.FC = () => {
return (
<header className="w-full border-b bg-white/60 backdrop-blur supports-[backdrop-filter]:bg-white/60 sticky top-0 z-50">
<header className="w-full border-b bg-white/60 backdrop-blur supports-backdrop-filter:bg-white/60 sticky top-0 z-50">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<Link href="/" className="flex items-center gap-2">
@ -18,7 +18,7 @@ const Header: React.FC = () => {
<Plus className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Create Template</span>
</Link>
<Link href="/template-preview" className="inline-flex items-center gap-2 text-gray-700 hover:text-gray-900">
<Link href="/templates" className="inline-flex items-center gap-2 text-gray-700 hover:text-gray-900">
<Layout className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Templates</span>
</Link>