refactor(nextjs): layout renderer & Preview by group slug

This commit is contained in:
shiva raj badu 2025-07-17 20:55:36 +05:45
parent 957ad959dd
commit afffb374c4
26 changed files with 546 additions and 388 deletions

View file

@ -9,29 +9,38 @@ interface LayoutInfo {
name?: string;
description?: string;
json_schema: any;
group: string;
groupName: string;
}
interface GroupSetting {
id: string;
name: string;
description: string;
ordered: boolean;
isDefault?: boolean;
}
interface GroupedLayoutsResponse {
group: string;
groupName: string;
files: string[];
settings: GroupSetting | null;
}
interface LayoutData {
layoutsById: Map<string, LayoutInfo>;
layoutsByGroup: Map<string, Set<string>>;
groupSettings: Map<string, GroupSetting>;
fileMap: Map<string, { fileName: string; groupName: string }>;
groupedLayouts: Map<string, LayoutInfo[]>;
layoutSchema: LayoutInfo[];
}
interface LayoutContextType {
layoutSchema: LayoutInfo[] | null;
groupSettings: Record<string, GroupSetting>;
idMapFileNames: Record<string, string>;
idMapSchema: Record<string, z.ZodSchema>;
idMapGroups: Record<string, string>;
getLayoutById: (layoutId: string) => LayoutInfo | null;
getLayoutByIdAndGroup: (layoutId: string, groupName: string) => LayoutInfo | null;
getLayoutsByGroup: (groupName: string) => LayoutInfo[];
getGroupSetting: (groupName: string) => GroupSetting | null;
getAllGroups: () => string[];
getAllLayouts: () => LayoutInfo[];
loading: boolean;
error: string | null;
getLayout: (layoutId: string) => React.ComponentType<{ data: any }> | null;
@ -42,52 +51,56 @@ interface LayoutContextType {
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
// Global layout cache
const layoutCache = new Map<string, React.ComponentType<{ data: any }>>();
const createCacheKey = (groupName: string, fileName: string): string => `${groupName}/${fileName}`;
export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [layoutSchema, setLayoutSchema] = useState<LayoutInfo[] | null>(null);
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
const [idMapFileNames, setIdMapFileNames] = useState<Record<string, string>>({});
const [idMapSchema, setIdMapSchema] = useState<Record<string, z.ZodSchema>>({});
const [idMapGroups, setIdMapGroups] = useState<Record<string, string>>({});
const [layoutData, setLayoutData] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPreloading, setIsPreloading] = useState(false);
const extractSchema = async (groupedLayoutsData: GroupedLayoutsResponse[]) => {
const buildData = async (groupedLayoutsData: GroupedLayoutsResponse[]) => {
const layouts: LayoutInfo[] = [];
const idMapFileNames: Record<string, string> = {};
const idMapSchema: Record<string, z.ZodSchema> = {};
const idMapGroups: Record<string, string> = {};
const groupSettings: Record<string, GroupSetting> = {};
const layoutsById = new Map<string, LayoutInfo>();
const layoutsByGroup = new Map<string, Set<string>>();
const groupSettingsMap = new Map<string, GroupSetting>();
const fileMap = new Map<string, { fileName: string; groupName: string }>();
const groupedLayouts = new Map<string, LayoutInfo[]>();
for (const groupData of groupedLayoutsData) {
// Store group settings
if (groupData.settings) {
groupSettings[groupData.group] = groupData.settings;
} else {
// Provide default settings if not available
groupSettings[groupData.group] = {
id: groupData.group,
name: groupData.group.charAt(0).toUpperCase() + groupData.group.slice(1),
description: `${groupData.group} presentation layouts`,
ordered: false,
isDefault: false
};
// Initialize group
if (!layoutsByGroup.has(groupData.groupName)) {
layoutsByGroup.set(groupData.groupName, new Set());
}
// group settings or default settings
const settings = groupData.settings || {
description: `${groupData.groupName} presentation layouts`,
ordered: false,
isDefault: false
};
groupSettingsMap.set(groupData.groupName, settings);
const groupLayouts: LayoutInfo[] = [];
for (const fileName of groupData.files) {
try {
const file = fileName.replace('.tsx', '').replace('.ts', '');
const module = await import(`@/presentation-layouts/${groupData.group}/${file}`);
const module = await import(`@/presentation-layouts/${groupData.groupName}/${file}`);
if (!module.default) {
toast({
title: `${file} has no default export`,
description: 'Please ensure the layout file exports a default component',
});
console.warn(`${file} has no default export`);
console.warn(`${file} has no default export`);
continue;
}
@ -96,83 +109,103 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
title: `${file} has no Schema export`,
description: 'Please ensure the layout file exports a Schema',
});
console.warn(`${file} has no Schema export`);
console.warn(`${file} has no Schema export`);
continue;
}
const layoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
const originalLayoutId = module.layoutId || file.toLowerCase().replace(/layout$/, '');
const uniqueKey = `${groupData.groupName}:${originalLayoutId}`;
const layoutName = module.layoutName || file.replace(/([A-Z])/g, ' $1').trim();
const layoutDescription = module.layoutDescription || `${layoutName} layout for presentations`;
const jsonSchema = z.toJSONSchema(module.Schema, {
override: (ctx) => {
delete ctx.jsonSchema.default;
},
});
const layout = {
id: layoutId,
const layout: LayoutInfo = {
id: originalLayoutId,
name: layoutName,
description: layoutDescription,
json_schema: jsonSchema,
group: groupData.group,
groupName: groupData.groupName,
};
idMapFileNames[layoutId] = fileName;
idMapSchema[layoutId] = module.Schema;
idMapGroups[layoutId] = groupData.group;
layoutsById.set(uniqueKey, layout);
layoutsByGroup.get(groupData.groupName)!.add(originalLayoutId);
fileMap.set(uniqueKey, { fileName, groupName: groupData.groupName });
groupLayouts.push(layout);
layouts.push(layout);
} catch (error) {
console.error(`Error extracting schema for ${fileName} from ${groupData.group}:`, error);
console.error(`💥 Error extracting schema for ${fileName} from ${groupData.groupName}:`, error);
}
}
// Cache grouped layouts
groupedLayouts.set(groupData.groupName, groupLayouts);
}
return { layouts, idMapFileNames, idMapSchema, idMapGroups, groupSettings };
return {
layoutsById,
layoutsByGroup,
groupSettings: groupSettingsMap,
fileMap,
groupedLayouts,
layoutSchema: layouts
};
};
const loadLayouts = async () => {
try {
setLoading(true);
setError(null);
const layoutResponse = await fetch('/api/layouts');
if (!layoutResponse.ok) {
throw new Error(`Failed to fetch layouts: ${layoutResponse.statusText}`);
}
const groupedLayoutsData: GroupedLayoutsResponse[] = await layoutResponse.json();
const response = await extractSchema(groupedLayoutsData);
setLayoutSchema(response?.layouts || []);
setGroupSettings(response?.groupSettings || {});
setIdMapFileNames(response?.idMapFileNames || {});
setIdMapSchema(response?.idMapSchema || {});
setIdMapGroups(response?.idMapGroups || {});
if (!groupedLayoutsData || groupedLayoutsData.length === 0) {
console.warn('⚠️ API returned empty data');
setError('No layout groups found');
return;
}
const data = await buildData(groupedLayoutsData);
setLayoutData(data);
// Preload layouts after loading schema
await preloadLayouts(response?.idMapFileNames || {}, response?.idMapGroups || {});
await preloadLayouts(data.fileMap);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load layouts';
setError(errorMessage);
console.error('Error loading layouts:', err);
console.error('💥 Error loading layouts:', err);
} finally {
setLoading(false);
}
};
const preloadLayouts = async (fileNames: Record<string, string>, groups: Record<string, string>) => {
const preloadLayouts = async (fileMap: Map<string, { fileName: string; groupName: string }>) => {
setIsPreloading(true);
try {
const layoutPromises = Object.entries(fileNames).map(async ([layoutId, fileName]) => {
const cacheKey = `${groups[layoutId]}/${fileName}`;
const layoutPromises = Array.from(fileMap.entries()).map(async ([layoutId, { fileName, groupName }]) => {
const cacheKey = createCacheKey(groupName, fileName);
if (!layoutCache.has(cacheKey)) {
const group = groups[layoutId];
const layoutName = fileName.replace('.tsx', '').replace('.ts', '');
const Layout = dynamic(
() => import(`@/presentation-layouts/${group}/${layoutName}`),
() => import(`@/presentation-layouts/${groupName}/${layoutName}`),
{
loading: () => <div className="w-full aspect-[16/9] bg-gray-100 animate-pulse rounded-lg" />,
ssr: false,
@ -182,7 +215,6 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
layoutCache.set(cacheKey, Layout);
}
});
await Promise.all(layoutPromises);
} catch (error) {
console.error('Error preloading layouts:', error);
@ -192,24 +224,37 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
};
const getLayout = (layoutId: string): React.ComponentType<{ data: any }> | null => {
const layoutName = idMapFileNames[layoutId];
const group = idMapGroups[layoutId];
if (!layoutData) return null;
if (!layoutName || !group) {
let fileInfo: { fileName: string; groupName: string } | undefined;
// Search through all fileMap entries to find the layout
for (const [key, info] of Array.from(layoutData.fileMap.entries())) {
// Extract original layout ID from unique key (format: "groupName:layoutId")
const originalId = key.split(':')[1];
if (originalId === layoutId) {
fileInfo = info;
break;
}
}
if (!fileInfo) {
console.warn(`No file info found for layout: ${layoutId}`);
return null;
}
const cacheKey = `${group}/${layoutName}`;
const cacheKey = createCacheKey(fileInfo.groupName, fileInfo.fileName);
// Return cached layout if available
if (layoutCache.has(cacheKey)) {
console.log(` Returning cached layout: ${cacheKey}`);
return layoutCache.get(cacheKey)!;
}
// Create and cache layout if not available
const file = layoutName.replace('.tsx', '').replace('.ts', '');
const file = fileInfo.fileName.replace('.tsx', '').replace('.ts', '');
const Layout = dynamic(
() => import(`@/presentation-layouts/${group}/${file}`),
() => import(`@/presentation-layouts/${fileInfo.groupName}/${file}`),
{
loading: () => <div className="w-full aspect-[16/9] bg-gray-100 animate-pulse rounded-lg" />,
ssr: false,
@ -220,17 +265,56 @@ export const LayoutProvider: React.FC<{ children: ReactNode }> = ({ children })
return Layout;
};
// Updated accessor methods to handle group-specific lookups
const getLayoutById = (layoutId: string): LayoutInfo | null => {
if (!layoutData) return null;
// Search through all entries to find the layout (since we don't know the group)
for (const [key, layout] of Array.from(layoutData.layoutsById.entries())) {
const originalId = key.split(':')[1];
if (originalId === layoutId) {
return layout;
}
}
return null;
};
const getLayoutByIdAndGroup = (layoutId: string, groupName: string): LayoutInfo | null => {
if (!layoutData) return null;
const uniqueKey = `${groupName}:${layoutId}`;
return layoutData.layoutsById.get(uniqueKey) || null;
};
const getLayoutsByGroup = (groupName: string): LayoutInfo[] => {
return layoutData?.groupedLayouts.get(groupName) || [];
};
const getGroupSetting = (groupName: string): GroupSetting | null => {
return layoutData?.groupSettings.get(groupName) || null;
};
const getAllGroups = (): string[] => {
return layoutData ? Array.from(layoutData.groupSettings.keys()) : [];
};
const getAllLayouts = (): LayoutInfo[] => {
return layoutData?.layoutSchema || [];
};
// Load layouts on mount
useEffect(() => {
loadLayouts();
}, []);
const contextValue: LayoutContextType = {
layoutSchema,
groupSettings,
idMapFileNames,
idMapSchema,
idMapGroups,
getLayoutById,
getLayoutByIdAndGroup,
getLayoutsByGroup,
getGroupSetting,
getAllGroups,
getAllLayouts,
loading,
error,
getLayout,

View file

@ -1,41 +1,32 @@
'use client'
import React, { useMemo } from 'react';
import { useLayout } from '../context/LayoutContext';
import { useSelector } from 'react-redux';
import { RootState } from '@/store/store';
interface LayoutInfo {
id: string;
name?: string;
description?: string;
json_schema: any;
group: string;
}
export const useGroupLayouts = () => {
const { layoutSchema, getLayout, loading } = useLayout();
const { presentationData } = useSelector((state: RootState) => state.presentationGeneration);
const {
getLayoutByIdAndGroup,
getLayoutsByGroup,
getLayout,
loading
} = useLayout();
// Get the selected group name from presentation data
// Get group-specific layout component with validation
const getGroupLayout = useMemo(() => {
return (layoutId: string, groupName: string) => {
// First check if the layout exists in the current group
const groupLayout = layoutSchema?.filter(layout => layout.group === groupName)
if (groupLayout) {
const layout = getLayoutByIdAndGroup(layoutId, groupName);
if (layout) {
return getLayout(layoutId);
}
// If layout not found in group, return null
console.warn(`Layout ${layoutId} not found in group ${groupName} `);
console.warn(`Layout ${layoutId} not found in group ${groupName}`);
return null;
};
}, [getLayout]);
}, [getLayoutByIdAndGroup, getLayout]);
const getGroupLayouts = useMemo(() => {
return (groupName: string) => {
return getLayoutsByGroup(groupName);
};
}, [getLayoutsByGroup]);
// Render slide content with group validation
const renderSlideContent = useMemo(() => {
@ -56,6 +47,7 @@ export const useGroupLayouts = () => {
return {
getGroupLayout,
getGroupLayouts,
renderSlideContent,
loading
};

View file

@ -21,45 +21,40 @@ const LayoutSelection: React.FC<LayoutSelectionProps> = ({
selectedLayoutGroup,
onSelectLayoutGroup
}) => {
const { layoutSchema, groupSettings, getLayout, loading } = useLayout();
const {
getLayoutsByGroup,
getGroupSetting,
getAllGroups,
getLayout,
loading
} = useLayout();
// Convert layoutSchema to grouped format using actual group settings
const layoutGroups: LayoutGroup[] = React.useMemo(() => {
if (!layoutSchema || layoutSchema.length === 0) return [];
const groups = getAllGroups();
// Group layouts by their group property
const groupMap = new Map<string, any[]>();
layoutSchema.forEach(layout => {
const groupName = layout.group || 'default';
if (!groupMap.has(groupName)) {
groupMap.set(groupName, []);
}
groupMap.get(groupName)?.push(layout);
});
if (groups.length === 0) return [];
// Convert to LayoutGroup format using actual group settings
const groups: LayoutGroup[] = [];
groupMap.forEach((layouts, groupName) => {
const settings = groupSettings[groupName];
const Groups: LayoutGroup[] = groups.map(groupName => {
const layouts = getLayoutsByGroup(groupName);
const settings = getGroupSetting(groupName);
const group: LayoutGroup = {
id: settings?.id || groupName,
name: settings?.name || groupName.charAt(0).toUpperCase() + groupName.slice(1),
return {
id: groupName,
name: groupName,
description: settings?.description || `${groupName} presentation layouts`,
ordered: settings?.ordered || false,
isDefault: settings?.isDefault || false,
slides: layouts.map((layout: any) => layout.id)
slides: layouts.map(layout => layout.id)
};
groups.push(group);
});
// Sort groups to put default first, then by name
return groups.sort((a, b) => {
return Groups.sort((a, b) => {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return a.name.localeCompare(b.name);
});
}, [layoutSchema, groupSettings]);
}, [getAllGroups, getLayoutsByGroup, getGroupSetting]);
// Auto-select first group when groups are loaded
useEffect(() => {

View file

@ -32,7 +32,10 @@ interface LayoutGroup {
const OutlinePage = () => {
const dispatch = useDispatch();
const router = useRouter();
const { layoutSchema } = useLayout();
const {
getLayoutById,
loading: layoutLoading,
} = useLayout();
const { presentation_id, outlines } = useSelector(
(state: RootState) => state.presentationGeneration
@ -192,10 +195,9 @@ const OutlinePage = () => {
});
try {
// Collect the actual schemas for layouts in the selected group
const groupLayoutSchemas = selectedLayoutGroup.slides
.map(slideId => {
const layout = layoutSchema?.find(l => l.id === slideId);
const layout = getLayoutById(slideId);
return layout ? {
id: layout.id,
name: layout.name,
@ -212,7 +214,6 @@ const OutlinePage = () => {
slides: groupLayoutSchemas
};
console.log("layoutData", layoutData);
const response = await PresentationGenerationApi.presentationPrepare({
presentation_id: presentation_id,
outlines: outlines,

View file

@ -144,7 +144,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
try {
const repairedJson = jsonrepair(accumulatedChunks);
const partialData = JSON.parse(repairedJson);
console.log('partialData', partialData)
if (partialData.slides) {
// Check if the length of slides has changed
if (

View file

@ -48,7 +48,6 @@ const SidePanel = ({
const { currentTheme, currentColors } = useSelector(
(state: RootState) => state.theme
);
console.log('presentationData', presentationData)
const dispatch = useDispatch();
// Use the centralized group layouts hook

View file

@ -3,8 +3,6 @@ import { promises as fs } from 'fs'
import path from 'path'
interface GroupSetting {
id: string;
name: string;
description: string;
ordered: boolean;
isDefault?: boolean;
@ -23,7 +21,7 @@ export async function GET() {
.filter(item => item.isDirectory())
.map(dir => dir.name)
const allLayouts: { group: string; files: string[]; settings: GroupSetting | null }[] = []
const allLayouts: { groupName: string; files: string[]; settings: GroupSetting | null }[] = []
// Scan each group directory for layout files and settings
for (const groupName of groupDirectories) {
@ -50,8 +48,6 @@ export async function GET() {
console.warn(`No settings.json found for group ${groupName} or invalid JSON`)
// Provide default settings if setting.json is missing or invalid
settings = {
id: groupName,
name: groupName.charAt(0).toUpperCase() + groupName.slice(1),
description: `${groupName} presentation layouts`,
ordered: false,
isDefault: false
@ -60,7 +56,7 @@ export async function GET() {
if (layoutFiles.length > 0) {
allLayouts.push({
group: groupName,
groupName: groupName,
files: layoutFiles,
settings: settings
})
@ -70,6 +66,7 @@ export async function GET() {
// Continue with other groups even if one fails
}
}
return NextResponse.json(allLayouts)
} catch (error) {

View file

@ -10,8 +10,6 @@ import {
} from "@/components/ui/popover";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/use-toast";
import { renderSlideContent } from "@/app/(presentation-generator)/components/slide_config";
import { useLayout } from "@/app/(presentation-generator)/context/LayoutContext";
import { useGroupLayouts } from "@/app/(presentation-generator)/hooks/useGroupLayouts";
export const PresentationCard = ({
@ -25,7 +23,6 @@ export const PresentationCard = ({
created_at: string;
slide: any
}) => {
console.log('slide', slide)
const router = useRouter();
const { renderSlideContent } = useGroupLayouts();
@ -63,18 +60,7 @@ export const PresentationCard = ({
window.location.reload();
};
// const LayoutComponent = useMemo(() => {
// const Layout = getLayout(slide.layout);
// if (!Layout) {
// return () => <div className="flex flex-col items-center justify-center h-full">
// Layout not found
// </div>;
// }
// return Layout;
// }, [slide.layout, getLayout]);
// const slideContent = useMemo(() => {
// return <LayoutComponent data={slide.content} />;
// }, [LayoutComponent, slide.content]);

View file

@ -0,0 +1,123 @@
'use client'
import React 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 } from 'lucide-react'
const GroupLayoutPreview = () => {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const { layoutGroup, loading, error, retry } = useGroupLayoutLoader(slug)
// Handle loading state
if (loading) {
return <LoadingStates type="loading" />
}
// Handle error state
if (error) {
return <LoadingStates type="error" message={error} onRetry={retry} />
}
// Handle empty state
if (!layoutGroup || layoutGroup.layouts.length === 0) {
return <LoadingStates type="empty" onRetry={retry} />
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Navigation */}
<div className="flex items-center gap-4 mb-4">
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push('/layout-preview')}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Groups
</Button>
</div>
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 capitalize">
{layoutGroup.groupName} Layouts
</h1>
<p className="text-gray-600 mt-2">
{layoutGroup.layouts.length} layout{layoutGroup.layouts.length !== 1 ? 's' : ''} {layoutGroup.settings.description}
</p>
</div>
</div>
</header>
{/* Layout Grid */}
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="space-y-8">
{layoutGroup.layouts.map((layout, index) => {
const { component: LayoutComponent, sampleData, name, fileName } = layout
return (
<Card
key={`${layoutGroup.groupName}-${index}`}
className="overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
{/* Layout Header */}
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">{name}</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-gray-500 font-mono">{fileName}</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{layoutGroup.groupName}
</span>
</div>
</div>
<div className="text-right">
<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>
</div>
</div>
</div>
{/* Layout Content */}
<div className="bg-gray-50">
<LayoutComponent data={sampleData} />
</div>
</Card>
)
})}
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t mt-16">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="text-center text-gray-600">
<p>{layoutGroup.groupName} {layoutGroup.layouts.length} components</p>
</div>
</div>
</footer>
</div>
)
}
export default GroupLayoutPreview

View file

@ -0,0 +1,158 @@
'use client'
import { useState, useEffect } from 'react'
import { LayoutInfo, LayoutGroup, GroupedLayoutsResponse, GroupSetting } from '../types'
import { toast } from '@/hooks/use-toast'
interface UseGroupLayoutLoaderReturn {
layoutGroup: LayoutGroup | null
loading: boolean
error: string | null
retry: () => void
}
export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderReturn => {
const [layoutGroup, setLayoutGroup] = useState<LayoutGroup | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadGroupLayouts = async () => {
try {
setLoading(true)
setError(null)
setLayoutGroup(null)
const response = await fetch('/api/layouts')
if (!response.ok) {
toast({
title: 'Error loading layouts',
description: response.statusText,
})
return
}
const groupedLayoutsData: GroupedLayoutsResponse[] = await response.json()
// Find the specific group by slug
const targetGroupData = groupedLayoutsData.find(
group => group.groupName.toLowerCase() === groupSlug.toLowerCase()
)
if (!targetGroupData) {
setError(`Group "${groupSlug}" not found`)
return
}
const groupLayouts: LayoutInfo[] = []
// Use settings from setting.json or provide defaults
const groupSettings: GroupSetting = targetGroupData.settings ? targetGroupData.settings : {
description: `${targetGroupData.groupName} presentation layouts`,
ordered: false,
isDefault: false
}
for (const fileName of targetGroupData.files) {
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`)
if (!module.default) {
toast({
title: `${layoutName} has no default export`,
description: 'Please ensure the layout file exports a default component',
})
console.warn(`${layoutName} has no default export`)
continue
}
if (!module.Schema) {
toast({
title: `${layoutName} is missing required Schema export`,
description: 'Please ensure the layout file exports a Schema',
})
console.error(`${layoutName} is missing required Schema export`)
continue
}
// Use empty object to let schema apply its default values
const sampleData = module.Schema.parse({})
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName
}
groupLayouts.push(layoutInfo)
} catch (importError) {
console.error(`Failed to import ${fileName} from ${targetGroupData.groupName}:`, importError)
// Try alternative import path
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${targetGroupData.groupName}/${layoutName}`)
if (module.default && module.Schema) {
const sampleData = module.Schema.parse({})
const layoutInfo: LayoutInfo = {
name: layoutName,
component: module.default,
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName
}
groupLayouts.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 ${targetGroupData.groupName}:`, altError)
}
}
}
if (groupLayouts.length === 0) {
toast({
title: 'No valid layouts found',
description: `No valid layouts found in "${groupSlug}" group.`,
})
setError(`No valid layouts found in "${groupSlug}" group.`)
} else {
setLayoutGroup({
groupName: targetGroupData.groupName,
layouts: groupLayouts,
settings: groupSettings
})
setError(null)
}
} catch (error) {
console.error('Error loading group layouts:', error)
setError(error instanceof Error ? error.message : 'Failed to load group layouts')
} finally {
setLoading(false)
}
}
const retry = () => {
loadGroupLayouts()
}
useEffect(() => {
if (groupSlug) {
loadGroupLayouts()
}
}, [groupSlug])
return {
layoutGroup,
loading,
error,
retry
}
}

View file

@ -27,12 +27,10 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
if (!response.ok) {
toast({
title: 'Error loading layouts',
description: response.statusText,
description: response.statusText,
})
return
}
const groupedLayoutsData: GroupedLayoutsResponse[] = await response.json()
const loadedGroups: LayoutGroup[] = []
const allLayouts: LayoutInfo[] = []
@ -40,11 +38,8 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
for (const groupData of groupedLayoutsData) {
const groupLayouts: LayoutInfo[] = []
// Use settings from setting.json or provide defaults
const groupSettings: GroupSetting = groupData.settings || {
id: groupData.group,
name: groupData.group.charAt(0).toUpperCase() + groupData.group.slice(1),
description: `${groupData.group} presentation layouts`,
const groupSettings: GroupSetting = groupData.settings ? groupData.settings : {
description: `${groupData.groupName} presentation layouts`,
ordered: false,
isDefault: false
}
@ -52,7 +47,7 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
for (const fileName of groupData.files) {
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${groupData.group}/${layoutName}`)
const module = await import(`@/presentation-layouts/${groupData.groupName}/${layoutName}`)
if (!module.default) {
toast({
@ -85,19 +80,19 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
schema: module.Schema,
sampleData,
fileName,
group: groupData.group
groupName: groupData.groupName
}
groupLayouts.push(layoutInfo)
allLayouts.push(layoutInfo)
} catch (importError) {
console.error(`Failed to import ${fileName} from ${groupData.group}:`, importError)
console.error(`Failed to import ${fileName} from ${groupData.groupName}:`, importError)
// Try alternative import path
try {
const layoutName = fileName.replace('.tsx', '').replace('.ts', '')
const module = await import(`@/presentation-layouts/${groupData.group}/${layoutName}`)
const module = await import(`@/presentation-layouts/${groupData.groupName}/${layoutName}`)
if (module.default && module.Schema) {
// Use empty object to let schema apply its default values
@ -108,7 +103,7 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
schema: module.Schema,
sampleData,
fileName,
group: groupData.group
groupName: groupData.groupName
}
groupLayouts.push(layoutInfo)
allLayouts.push(layoutInfo)
@ -116,27 +111,20 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
console.error(`${layoutName} is missing required exports (default component or Schema)`)
}
} catch (altError) {
console.error(`Alternative import also failed for ${fileName} from ${groupData.group}:`, altError)
console.error(`Alternative import also failed for ${fileName} from ${groupData.groupName}:`, altError)
}
}
}
if (groupLayouts.length > 0) {
loadedGroups.push({
group: groupData.group,
groupName: groupData.groupName,
layouts: groupLayouts,
settings: groupSettings
})
}
}
// Sort groups to put default first, then by name
loadedGroups.sort((a, b) => {
if (a.settings.isDefault && !b.settings.isDefault) return -1
if (!a.settings.isDefault && b.settings.isDefault) return 1
return a.settings.name.localeCompare(b.settings.name)
})
if (allLayouts.length === 0) {
toast({
title: 'No valid layouts found',

View file

@ -1,29 +1,15 @@
'use client'
import React, { useRef } from 'react'
import React from 'react'
import { useRouter } from 'next/navigation'
import { useLayoutLoader } from './hooks/useLayoutLoader'
import LoadingStates from './components/LoadingStates'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ExternalLink } from 'lucide-react'
const LayoutPreview = () => {
const { layoutGroups, layouts, loading, error, retry } = useLayoutLoader()
const sectionRefs = useRef<Record<string, HTMLElement | null>>({})
const scrollToSection = (groupName: string) => {
const element = sectionRefs.current[groupName]
if (element) {
const headerHeight = 140 // Account for sticky header + nav
const elementPosition = element.offsetTop - headerHeight
window.scrollTo({
top: elementPosition,
behavior: 'smooth'
})
}
}
const setSectionRef = (groupName: string) => (el: HTMLElement | null) => {
sectionRefs.current[groupName] = el
}
const router = useRouter()
// Handle loading state
if (loading) {
@ -42,8 +28,7 @@ const LayoutPreview = () => {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900">Layout Preview</h1>
@ -53,94 +38,51 @@ const LayoutPreview = () => {
</div>
</div>
{/* Group Navigation Tags */}
<div className="border-t bg-gray-50">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex flex-wrap gap-3 justify-center">
{/* Group Navigation Cards */}
<div className="border-t bg-gray-50 min-h-screen flex justify-center items-center">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{layoutGroups.map((group) => (
<button
key={group.group}
onClick={() => scrollToSection(group.group)}
className="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-white border border-gray-200 text-gray-700 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700 transition-all duration-200 shadow-sm hover:shadow-md"
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/layout-preview/${group.groupName}`)}
>
<span className="capitalize">{group.group}</span>
<span className="ml-2 px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs">
{group.layouts.length}
</span>
</button>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900 capitalize group-hover:text-blue-600 transition-colors">
{group.groupName}
</h3>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
{group.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">
{group.settings.description}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{group.layouts.length} layout{group.layouts.length !== 1 ? 's' : ''}
</span>
{group.settings.isDefault && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Default
</span>
)}
</div>
</div>
</Card>
))}
</div>
</div>
</div>
</header>
</div>
{/* Layout Groups */}
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="space-y-12">
{layoutGroups.map((group) => (
<section
key={group.group}
ref={setSectionRef(group.group)}
className="space-y-6"
>
{/* Group Title */}
<div className="border-b border-gray-200 pb-4">
<h2 className="text-2xl font-bold text-gray-900 capitalize">
{group.group} Layouts
</h2>
<p className="text-gray-600 mt-1">
{group.layouts.length} layout{group.layouts.length !== 1 ? 's' : ''}
</p>
</div>
{/* Group Layouts Grid */}
<div className="space-y-8">
{group.layouts.map((layout, index) => {
const { component: LayoutComponent, sampleData, name, fileName } = layout
return (
<Card key={`${group.group}-${index}`} className="overflow-hidden shadow-md hover:shadow-lg transition-shadow">
{/* Layout Header */}
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">{name}</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm text-gray-500 font-mono">{fileName}</span>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{group.group}
</span>
</div>
</div>
<div className="text-right">
<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>
</div>
</div>
</div>
{/* Layout Content */}
<div className="bg-gray-50">
<LayoutComponent data={sampleData} />
</div>
</Card>
)
})}
</div>
</section>
))}
</div>
</main>
{/* Footer */}
<footer className="bg-white border-t mt-16">
<div className="max-w-7xl mx-auto px-6 py-8">
<div className="text-center text-gray-600">
<p>Layout Preview System {layoutGroups.length} groups {layouts.length} components</p>
</div>
</div>
</footer>
</div>
)
}

View file

@ -1,6 +1,3 @@
/**
* Shared types for the Layout Preview system
*/
export interface LayoutInfo {
name: string
@ -8,25 +5,23 @@ export interface LayoutInfo {
schema: any
sampleData: any
fileName: string
group: string
groupName: string
}
export interface GroupSetting {
id: string;
name: string;
description: string;
ordered: boolean;
isDefault?: boolean;
}
export interface LayoutGroup {
group: string
groupName: string
layouts: LayoutInfo[]
settings: GroupSetting
}
export interface GroupedLayoutsResponse {
group: string
groupName: string
files: string[]
settings: GroupSetting | null
}

View file

@ -1,6 +1,4 @@
{
"id": "default",
"name": "Default",
"description": "Default layout for presentations",
"ordered": false,
"isDefault": true

View file

@ -1,97 +0,0 @@
export interface LayoutGroup {
id: string;
name: string;
description: string;
ordered: boolean;
isDefault?: boolean;
slides: string[];
}
export const ProfessionalLayoutGroup: LayoutGroup = {
id: 'professional',
name: 'Professional',
description: 'Clean, corporate designs perfect for business presentations',
ordered: true,
isDefault: true,
slides: [
'first-slide',
'content-slide',
'bullet-point-slide',
'comparison-slide',
'type4-slide',
'statistics-slide',
'team-slide',
'quote-slide'
]
}
export const CreativeLayoutGroup: LayoutGroup = {
id: 'creative',
name: 'Creative',
description: 'Vibrant, artistic layouts for innovative and creative presentations',
ordered: false,
slides: [
'image-slide',
'icon-slide',
'card-slide',
'type1-slide',
'type2-slide',
'type3-slide',
'process-slide'
]
}
export const ModernLayoutGroup: LayoutGroup = {
id: 'modern',
name: 'Modern',
description: 'Contemporary designs with clean lines and sophisticated layouts',
ordered: true,
slides: [
'type5-slide',
'type6-slide',
'type7-slide',
'type8-slide',
'timeline-slide',
'type2-timeline-slide',
'number-box-slide'
]
}
export const MinimalLayoutGroup: LayoutGroup = {
id: 'minimal',
name: 'Minimal',
description: 'Simple, focused layouts that emphasize content over decoration',
ordered: false,
slides: [
'content-slide',
'bullet-point-slide',
'type2-numbered-slide',
'quote-slide',
'statistics-slide'
]
}
export const LayoutGroups = [
ProfessionalLayoutGroup,
CreativeLayoutGroup,
ModernLayoutGroup,
MinimalLayoutGroup
];
export const getDefaultLayoutGroup = (): LayoutGroup => {
return LayoutGroups.find(group => group.isDefault) || ProfessionalLayoutGroup;
};
export const getAllLayouts = (): string[] => {
const allLayouts = new Set<string>();
LayoutGroups.forEach(group => {
group.slides.forEach(slide => allLayouts.add(slide));
});
return Array.from(allLayouts);
};
export const getGroupByLayoutId = (layoutId: string): LayoutGroup | undefined => {
return LayoutGroups.find(group => group.slides.includes(layoutId));
};

View file

@ -74,7 +74,7 @@ const ComparisonSlideLayout: React.FC<ComparisonSlideLayoutProps> = ({ data: sli
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -58,7 +58,7 @@ const NumberBoxSlideLayout: React.FC<NumberBoxSlideLayoutProps> = ({ data: slide
// const data = numberBoxSlideSchema.parse(slideData || {})
return (
<div className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300">
<div className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300">
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-[0.02] print:opacity-[0.01]">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-gray-200 to-transparent transform -rotate-45 scale-150"></div>

View file

@ -86,7 +86,7 @@ const TimelineSlideLayout: React.FC<TimelineSlideLayoutProps> = ({ data: slideDa
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -1,6 +1,4 @@
{
"id": "modern",
"name": "Modern",
"description": "Contemporary designs with clean lines and sophisticated layouts",
"ordered": false,
"isDefault": false

View file

@ -44,7 +44,7 @@ const BulletPointSlideLayout: React.FC<BulletPointSlideLayoutProps> = ({ data: s
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -34,7 +34,7 @@ const ContentSlideLayout: React.FC<ContentSlideLayoutProps> = ({ data: slideData
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData?.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -41,7 +41,7 @@ const FirstSlideLayout: React.FC<FirstSlideLayoutProps> = ({ data: slideData })
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -37,7 +37,7 @@ interface ImageSlideLayoutProps {
const ImageSlideLayout: React.FC<ImageSlideLayoutProps> = ({ data: slideData }) => {
return (
<div className="relative w-full aspect-[16/9] flex bg-gradient-to-br from-slate-50 to-slate-100 overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300">
<div className="relative w-full aspect-[16/9] flex bg-white overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300">
{/* Left panel - Image */}
<div className="flex-1 relative">
<img

View file

@ -46,7 +46,7 @@ const QuoteSlideLayout: React.FC<QuoteSlideLayoutProps> = ({ data: slideData })
return (
<div
className="relative w-full aspect-[16/9] flex flex-col bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200"
className="relative w-full aspect-[16/9] flex flex-col bg-white overflow-hidden shadow-2xl border border-slate-200"
style={slideData?.backgroundImage ? {
backgroundImage: `linear-gradient(135deg, rgba(0,0,0,0.5), rgba(0,0,0,0.7)), url(${slideData?.backgroundImage.__image_url__})`,
backgroundSize: 'cover',

View file

@ -75,7 +75,7 @@ const TeamSlideLayout: React.FC<TeamSlideLayoutProps> = ({ data: slideData }) =>
return (
<div
className="relative w-full aspect-[16/9] bg-gradient-to-br from-slate-50 via-white to-slate-100 overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
className="relative w-full aspect-[16/9] bg-white overflow-hidden shadow-2xl border border-slate-200 print:shadow-none print:border-gray-300"
style={slideData?.backgroundImage ? {
backgroundImage: `url("${slideData.backgroundImage.__image_url__}")`,
backgroundSize: 'cover',

View file

@ -1,6 +1,5 @@
{
"id": "professional",
"name": "Professional",
"description": "Clean, corporate designs perfect for business presentations",
"ordered": false
"ordered": false,
"isDefault": false
}