merge: for name and description of templates

This commit is contained in:
Suraj Jha 2025-08-10 00:29:30 +05:45
commit 353f8e41d1
No known key found for this signature in database
GPG key ID: 5AC6C16355CE2C14
11 changed files with 317 additions and 236 deletions

View file

@ -69,6 +69,7 @@ export interface LayoutContextType {
isPreloading: boolean;
cacheSize: number;
refetch: () => Promise<void>;
getCustomTemplateFonts: (presentationId: string) => string[] | null;
}
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
@ -129,6 +130,7 @@ export const LayoutProvider: React.FC<{
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPreloading, setIsPreloading] = useState(false);
const [customTemplateFonts, setCustomTemplateFonts] = useState<Map<string, string[]>>(new Map());
const dispatch = useDispatch();
const buildData = async (groupedLayoutsData: GroupedLayoutsResponse[]) => {
@ -336,7 +338,6 @@ export const LayoutProvider: React.FC<{
const LoadCustomLayouts = async () => {
const layouts: LayoutInfo[] = [];
const layoutsById = new Map<string, LayoutInfo>();
const layoutsByGroup = new Map<string, Set<string>>();
const groupSettingsMap = new Map<string, GroupSetting>();
@ -348,9 +349,9 @@ export const LayoutProvider: React.FC<{
"/api/v1/ppt/template-management/summary"
);
const customGroupData = await customGroupResponse.json();
const customFonts = new Map<string, string[]>();
const customGroup = customGroupData.presentations;
for (const group of customGroup) {
const groupName = `custom-${group.presentation_id}`;
fullDataByGroup.set(groupName, []);
@ -363,6 +364,9 @@ export const LayoutProvider: React.FC<{
);
const customLayoutsData = await customLayoutResponse.json();
const allLayout = customLayoutsData.layouts;
const settings = {
description: `Custom presentation layouts`,
@ -402,6 +406,8 @@ export const LayoutProvider: React.FC<{
layoutCache.set(cacheKey, module.default);
}
customFonts.set(presentationId, i.fonts);
const originalLayoutId =
module.layoutId ||
i.layout_name.toLowerCase().replace(/layout$/, "");
@ -447,6 +453,7 @@ export const LayoutProvider: React.FC<{
groupLayouts.push(layout);
layouts.push(layout);
}
setCustomTemplateFonts(customFonts);
// Cache grouped layouts
groupedLayouts.set(groupName, groupLayouts);
fullDataByGroup.set(groupName, groupFullData);
@ -454,6 +461,7 @@ export const LayoutProvider: React.FC<{
} catch (err: any) {
console.error("Compilation error:", err);
}
return {
layoutsById,
@ -548,6 +556,9 @@ export const LayoutProvider: React.FC<{
const getFullDataByGroup = (groupName: string): FullDataInfo[] => {
return layoutData?.fullDataByGroup.get(groupName) || [];
};
const getCustomTemplateFonts = (presentationId: string): string[] | null => {
return customTemplateFonts.get(presentationId) || null;
};
// Load layouts on mount
useEffect(() => {
@ -562,6 +573,7 @@ export const LayoutProvider: React.FC<{
getAllGroups,
getAllLayouts,
getFullDataByGroup,
getCustomTemplateFonts,
loading,
error,
getLayout,

View file

@ -1,7 +1,6 @@
'use client'
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Save, X, Code } from "lucide-react";
import { ProcessedSlide } from "../../types";
import Editor from 'react-simple-code-editor';
@ -10,6 +9,7 @@ import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-jsx';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface HtmlEditorProps {
slide: ProcessedSlide;
@ -42,58 +42,56 @@ export const HtmlEditor: React.FC<HtmlEditorProps> = ({
};
return (
<Card className="border-2 border-purple-200 bg-purple-50">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-purple-600" />
<span className="text-purple-800">HTML Editor</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
>
<Save size={14} />
Save HTML
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 ">
<div
dangerouslySetInnerHTML={{
__html: htmlContent,
}}
/>
<p className="text-base text-gray-800">Edit the HTML code to customize the slide layout.</p>
{/* Render code editor */}
<div className="container__content_area">
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<span className="flex items-center gap-2 text-purple-800">
<Code className="w-5 h-5 text-purple-600" />
HTML Editor
</span>
</SheetTitle>
</SheetHeader>
<Editor
value={htmlContent}
onValueChange={htmlContent => setHtmlContent(htmlContent)}
highlight={htmlContent => highlight(htmlContent, languages.jsx!,'jsx')}
padding={10}
id="html-editor"
name="html-editor"
className="container__editor"
/>
</div>
</CardContent>
</Card>
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
<div className="container__content_area">
<Editor
value={htmlContent}
onValueChange={html => setHtmlContent(html)}
highlight={code => highlight(code, languages.jsx!, 'jsx')}
padding={10}
id="html-editor"
name="html-editor"
className="container__editor"
/>
</div>
</div>
<SheetFooter className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<div></div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
size="sm"
>
<Save size={14} />
Save HTML
</Button>
</div>
</SheetTitle>
</SheetFooter>
</SheetContent>
</Sheet>
);
};

View file

@ -13,8 +13,29 @@ const GroupLayouts: React.FC<GroupLayoutsProps> = ({
onSelectLayoutGroup,
selectedLayoutGroup,
}) => {
const { getFullDataByGroup } = useLayout();
const { getFullDataByGroup,getCustomTemplateFonts } = useLayout();
const layoutGroup = getFullDataByGroup(group.id);
const fonts = getCustomTemplateFonts(group.id.split("custom-")[1]);
console.log("fonts here", fonts);
if(fonts){
const injectFonts = (fontUrls: string[]) => {
fontUrls.forEach((fontUrl) => {
if (!fontUrl) return;
const existingStyle = document.querySelector(`style[data-font-url="${fontUrl}"]`);
if (existingStyle) return;
const fileName = fontUrl.split("/").pop() || "CustomFont";
const baseName = fileName.replace(/\.[a-zA-Z0-9]+$/, "");
const fontFamily = baseName.replace(/[^A-Za-z0-9_-]/g, "_");
const ext = (fileName.split(".").pop() || "ttf").toLowerCase();
const format = ext === "otf" ? "opentype" : ext === "woff" ? "woff" : ext === "woff2" ? "woff2" : "truetype";
const style = document.createElement("style");
style.setAttribute("data-font-url", fontUrl);
style.textContent = `@font-face { font-family: '${fontFamily}'; src: url('${fontUrl}') format('${format}'); font-display: swap; }`;
document.head.appendChild(style);
});
};
injectFonts(fonts);
}
return (
<div
onClick={() => onSelectLayoutGroup(group)}

View file

@ -90,6 +90,22 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
});
}
}, [layoutGroups, selectedLayoutGroup, onSelectLayoutGroup]);
useEffect(() => {
if (loading) {
return;
}
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);
}
}, []);
if (loading) {
return (

View file

@ -175,7 +175,11 @@ const SidePanel = ({
? "bg-[#5141e5] hover:bg-[#4638c7]"
: "bg-white hover:bg-white"
}`}
onClick={() => setActive("grid")}
onClick={() => {
if (!isStreaming) {
setActive("grid")
}
}}
>
<LayoutList
className={`${active === "grid" ? "text-white" : "text-black"
@ -190,7 +194,11 @@ const SidePanel = ({
? "bg-[#5141e5] hover:bg-[#4638c7]"
: "bg-white hover:bg-white"
}`}
onClick={() => setActive("list")}
onClick={() =>{
if(!isStreaming){
setActive("list")
}
}}
>
<ListTree
className={`${active === "list" ? "text-white" : "text-black"

View file

@ -104,7 +104,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
}, [renderSlideContent, slide, isStreaming]);
useEffect(() => {
if (isStreaming || loading) {
if (loading) {
return;
}
if (slide.layout_group.includes("custom")) {

View file

@ -397,3 +397,4 @@ import { useDrawingCanvas } from "../../custom-template/hooks/useDrawingCanvas";
};
export default GroupLayoutPreview;

View file

@ -1,22 +1,85 @@
"use client";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
// import { useGroupLayoutLoader } from '../hooks/useGroupLayoutLoader'
import LoadingStates from "../components/LoadingStates";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Home, Trash2 } from "lucide-react";
import { ArrowLeft, Home, Trash2, Code, Save, X, Pencil } from "lucide-react";
import { useLayout } from "@/app/(presentation-generator)/context/LayoutContext";
import Editor from "react-simple-code-editor";
import { highlight, languages } from "prismjs";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-markup";
import "prismjs/components/prism-jsx";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
const GroupLayoutPreview = () => {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
const { getFullDataByGroup, loading,refetch } = useLayout();
const { getFullDataByGroup, loading, refetch } = useLayout();
const layoutGroup = getFullDataByGroup(slug);
const [templateMeta, setTemplateMeta] = React.useState<{ name?: string; description?: string } | null>(null);
const presentationId = slug.replace("custom-", "");
const isCustom = slug.includes("custom-");
const [editorOpen, setEditorOpen] = useState(false);
const [currentCode, setCurrentCode] = useState("");
const [currentLayoutName, setCurrentLayoutName] = useState("");
const [currentLayoutId, setCurrentLayoutId] = useState("");
const [currentFonts, setCurrentFonts] = useState<string[] | undefined>(undefined);
const [isSaving, setIsSaving] = useState(false);
const [layoutsMap, setLayoutsMap] = useState<Record<string, { layout_id: string; layout_name: string; layout_code: string; fonts?: string[] }>>({});
const injectFonts = (fontUrls: string[]) => {
fontUrls.forEach((fontUrl) => {
if (!fontUrl) return;
const existingStyle = document.querySelector(`style[data-font-url="${fontUrl}"]`);
if (existingStyle) return;
const fileName = fontUrl.split("/").pop() || "CustomFont";
const baseName = fileName.replace(/\.[a-zA-Z0-9]+$/, "");
const fontFamily = baseName.replace(/[^A-Za-z0-9_-]/g, "_");
const ext = (fileName.split(".").pop() || "ttf").toLowerCase();
const format = ext === "otf" ? "opentype" : ext === "woff" ? "woff" : ext === "woff2" ? "woff2" : "truetype";
const style = document.createElement("style");
style.setAttribute("data-font-url", fontUrl);
style.textContent = `@font-face { font-family: '${fontFamily}'; src: url('${fontUrl}') format('${format}'); font-display: swap; }`;
document.head.appendChild(style);
});
};
useEffect(() => {
const loadCustomLayouts = async () => {
if (!isCustom) return;
try {
const res = await fetch(`/api/v1/ppt/layout-management/get-layouts/${presentationId}`);
if (!res.ok) return;
const data = await res.json();
const map: Record<string, { layout_id: string; layout_name: string; layout_code: string; fonts?: string[] }> = {};
for (const l of data.layouts || []) {
map[l.layout_name] = {
layout_id: l.layout_id,
layout_name: l.layout_name,
layout_code: l.layout_code,
fonts: l.fonts,
};
}
setLayoutsMap(map);
// Inject all fonts used by this custom group's layouts
// const allFonts: string[] = [];
// Object.values(map).forEach((entry) => {
// (entry.fonts || []).forEach((f) => allFonts.push(f));
// });
injectFonts(map[0].fonts || []);
} catch (e) {
// noop
}
};
loadCustomLayouts();
}, [isCustom, presentationId]);
useEffect(() => {
const existingScript = document.querySelector(
@ -30,20 +93,15 @@ const GroupLayoutPreview = () => {
}
}, [slug]);
// Ensure fonts are injected if layoutsMap changes dynamically
useEffect(() => {
// Load template meta for custom groups
if (slug.startsWith("custom-")) {
const presentationId = slug.replace("custom-", "");
fetch(`/api/v1/ppt/template-management/get-templates/${presentationId}`)
.then(res => res.json())
.then(data => {
if (data?.template) {
setTemplateMeta({ name: data.template.name, description: data.template.description });
}
})
.catch(() => setTemplateMeta(null));
}
}, [slug]);
if (!isCustom) return;
const allFonts: string[] = [];
Object.values(layoutsMap).forEach((entry) => {
(entry.fonts || []).forEach((f) => allFonts.push(f));
});
if (allFonts.length) injectFonts(allFonts);
}, [layoutsMap, isCustom]);
// Handle loading state
if (loading) {
@ -65,6 +123,63 @@ const GroupLayoutPreview = () => {
router.push("/layout-preview");
}
}
const openEditor = (layoutName: string) => {
const entry = layoutsMap[layoutName];
if (!entry) return;
setCurrentLayoutName(entry.layout_name);
setCurrentLayoutId(entry.layout_id);
setCurrentCode(entry.layout_code || "");
setCurrentFonts(entry.fonts);
// Make sure fonts for this layout are loaded before editing
injectFonts(entry.fonts || []);
setEditorOpen(true);
};
const handleCancel = () => {
// reset to original code
const entry = layoutsMap[currentLayoutName];
if (entry) setCurrentCode(entry.layout_code || "");
setEditorOpen(false);
};
const handleSave = async () => {
try {
setIsSaving(true);
const payload = {
layouts: [
{
presentation_id: presentationId,
layout_id: currentLayoutId,
layout_name: currentLayoutName,
layout_code: currentCode,
fonts: currentFonts,
},
],
};
const res = await fetch(`/api/v1/ppt/layout-management/save-layouts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) return;
// update cache map
setLayoutsMap((prev) => ({
...prev,
[currentLayoutName]: {
layout_id: currentLayoutId,
layout_name: currentLayoutName,
layout_code: currentCode,
fonts: currentFonts,
},
}));
await refetch();
setEditorOpen(false);
} finally {
setIsSaving(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
@ -139,10 +254,22 @@ const GroupLayoutPreview = () => {
</span>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2">
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-700">
Layout #{index + 1}
</div>
{isCustom && (
<Button
variant="outline"
size="sm"
className="flex items-center gap-2 bg-blue-50 border border-blue-400 text-blue-700"
onClick={() => openEditor(fileName)}
disabled={!layoutsMap[fileName]}
title={!layoutsMap[fileName] ? "Loading layout code..." : "Edit layout code"}
>
<Pencil className="w-4 h-4" /> Edit
</Button>
)}
</div>
</div>
</div>
@ -167,6 +294,61 @@ const GroupLayoutPreview = () => {
</div>
</div>
</footer>
{/* Right-side Sheet Editor */}
{isCustom && (
<Sheet open={editorOpen} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<span className="flex items-center gap-2 text-purple-800">
<Code className="w-5 h-5 text-purple-600" />
HTML Editor
</span>
</SheetTitle>
</SheetHeader>
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
<div className="container__content_area">
<Editor
value={currentCode}
onValueChange={(code) => setCurrentCode(code)}
highlight={(code) => highlight(code, languages.jsx!, "jsx")}
padding={10}
id="layout-code-editor"
name="layout-code-editor"
className="container__editor"
/>
</div>
</div>
<SheetFooter className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<div></div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
disabled={isSaving}
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
size="sm"
disabled={isSaving}
>
<Save size={14} />
Save HTML
</Button>
</div>
</SheetTitle>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</div>
);
};

View file

@ -1,157 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { LayoutInfo, LayoutGroup, GroupedLayoutsResponse, GroupSetting } from '../types'
import { toast } from 'sonner'
interface UseLayoutLoaderReturn {
layoutGroups: LayoutGroup[]
layouts: LayoutInfo[]
loading: boolean
error: string | null
retry: () => void
}
export const useLayoutLoader = (): UseLayoutLoaderReturn => {
const [layoutGroups, setLayoutGroups] = useState<LayoutGroup[]>([])
const [layouts, setLayouts] = useState<LayoutInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadAllLayouts = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch('/api/templates')
if (!response.ok) {
toast.error('Error loading templates', {
description: response.statusText,
})
return
}
const groupedLayoutsData: GroupedLayoutsResponse[] = await response.json()
const loadedGroups: LayoutGroup[] = []
const allLayouts: LayoutInfo[] = []
for (const groupData of groupedLayoutsData) {
const groupLayouts: LayoutInfo[] = []
const groupSettings: GroupSetting = groupData.settings ? groupData.settings : {
description: `${groupData.groupName} presentation templates`,
ordered: false,
default: false
}
for (const fileName of groupData.files) {
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${groupData.groupName}/${layoutName}`)
if (!module.default) {
toast.error(`${layoutName} has no default export`, {
description: 'Please ensure the template file exports a default component',
})
console.warn(`${layoutName} has no default export`)
throw new Error(`${layoutName} has no default export`)
}
if (!module.Schema) {
toast.error(`${layoutName} is missing required Schema export`, {
description: 'Please ensure the template file exports a Schema',
})
console.error(`${layoutName} is missing required Schema export`)
throw new Error(`${layoutName} is missing required Schema export`)
}
const sampleData = module.Schema.parse({})
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: groupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
allLayouts.push(layoutInfo)
} catch (importError) {
console.error(`Failed to import ${fileName} from ${groupData.groupName}:`, importError)
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${groupData.groupName}/${layoutName}`)
if (module.default && module.Schema) {
const sampleData = module.Schema.parse({})
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: groupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
allLayouts.push(layoutInfo)
} else {
console.error(`${layoutName} is missing required exports (default component or Schema)`)
}
} catch (altError) {
console.error(`Alternative import also failed for ${fileName} from ${groupData.groupName}:`, altError)
}
}
}
if (groupLayouts.length > 0) {
loadedGroups.push({
groupName: groupData.groupName,
layouts: groupLayouts,
settings: groupSettings
})
}
}
if (allLayouts.length === 0) {
toast.error('No valid templates found', {
description: 'Make sure your template files export both a default component and a Schema.',
})
setError('No valid templates found. Make sure your template files export both a default component and a Schema.')
} else {
setLayoutGroups(loadedGroups)
setLayouts(allLayouts)
setError(null)
}
} catch (error) {
console.error('Error loading templates:', error)
setError(error instanceof Error ? error.message : 'Failed to load templates')
} finally {
setLoading(false)
}
}
const retry = () => {
loadAllLayouts()
}
useEffect(() => {
loadAllLayouts()
}, [])
return {
layoutGroups,
layouts,
loading,
error,
retry
}
}

View file

@ -443,7 +443,7 @@ thead {
.container__content_area {
tab-size: 4ch;
max-height: 400px;
/* max-height: 600px; */
overflow: auto;
margin: 1.67em 0;
}