feat(nextjs): New slide add featture added

This commit is contained in:
shiva raj badu 2025-07-19 22:11:18 +05:45
parent 2d49e536ca
commit 5f58828d02
No known key found for this signature in database
12 changed files with 138 additions and 232 deletions

View file

@ -107,6 +107,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
const root = ReactDOM.createRoot(tiptapContainer);
root.render(
<TiptapText
key={trimmedText}
content={trimmedText}
onContentChange={(content: string) => {
if (dataPath && onContentChange) {

View file

@ -1,152 +1,46 @@
import { Trash2 } from 'lucide-react';
import React from 'react'
import { useGroupLayoutLoader } from '@/app/layout-preview/hooks/useGroupLayoutLoader';
import { useDispatch } from 'react-redux';
import { addNewSlide } from '@/store/slices/presentationGeneration';
import { Loader2 } from 'lucide-react';
interface NewSlideProps {
onSelectLayout: (type: number) => void;
setShowNewSlideSelection: (show: boolean) => void;
group: string;
index: number;
presentationId: string;
}
const LayoutPreview = ({ type }: { type: string }) => {
switch (type) {
case 'type1':
return (
<div className="w-full h-[120px] bg-white p-3 flex items-center gap-2">
<div className="w-1/2 space-y-1.5">
<div className="h-3 bg-gray-200 w-3/4"></div>
<div className="h-2 bg-gray-100 w-3/4"></div>
</div>
<div className="w-1/2 h-full bg-gray-100 flex items-center justify-center">
<p className='text-gray-500 text-sm'>image</p>
</div>
</div>
)
case 'type2':
return (
<div className="w-full h-[120px] bg-white p-3 flex flex-col gap-2">
<div className="h-3 bg-gray-200 w-1/2 mx-auto"></div>
<div className="flex gap-2 flex-1">
{[1, 2, 3].map((i) => (
<div key={i} className="flex-1 bg-gray-100 p-1.5">
<div className="h-2 bg-gray-200 w-3/4 mb-1"></div>
<div className="h-1.5 bg-gray-50 w-full"></div>
</div>
))}
</div>
</div>
)
case 'type4':
return (
<div className="w-full h-[120px] bg-white p-3 flex flex-col gap-2">
<div className="h-3 bg-gray-200 w-1/2 mx-auto"></div>
<div className="grid grid-cols-3 gap-2 flex-1">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-gray-100 p-1.5">
<div className="h-8 bg-gray-200 mb-1 flex items-center justify-center">
<p className='text-gray-500 text-xs'>image</p>
</div>
<div className="h-1.5 bg-gray-50 w-3/4"></div>
</div>
))}
</div>
</div>
)
case 'type5':
return (
<div className="w-full h-[120px] bg-white p-3 flex flex-col gap-2">
<div className="h-2 bg-gray-200 w-1/2 "></div>
<div className="w-full grid grid-cols-2 h-full items-center gap-2">
<div className=" bg-gray-100 h-full w-full flex items-center justify-center">
<p className='text-gray-500 text-xs'>chart</p>
</div>
<div className="h-4 bg-gray-100 w-full"></div>
</div>
</div>
)
case 'type6':
return (
<div className="w-full h-[120px] bg-white p-3 flex gap-2">
<div className="w-1/2 space-y-1.5">
<div className="h-3 bg-gray-200 w-3/4"></div>
<div className="h-2 bg-gray-100 w-full"></div>
</div>
<div className="w-1/2 space-y-1.5">
{[1, 2].map((i) => (
<div key={i} className="flex gap-1.5 bg-gray-50 p-1.5">
<div className="w-4 h-4 bg-gray-200 rounded-full flex-shrink-0"></div>
<div className="flex-1">
<div className="h-1.5 bg-gray-200 w-3/4"></div>
</div>
</div>
))}
</div>
</div>
)
case 'type7':
return (
<div className="w-full h-[120px] bg-white p-3 flex flex-col gap-2">
<div className="h-3 bg-gray-200 w-1/2 mx-auto"></div>
<div className="flex justify-between px-6">
{[1, 2, 3].map((i) => (
<div key={i} className="text-center bg-gray-100 h-full flex flex-col items-center justify-center">
<div className="w-4 h-4 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-1">
<p className='text-gray-500 text-xs'>Icon</p>
</div>
<div className="h-1.5 bg-gray-200 w-12 mb-1"></div>
<div className="h-5 bg-gray-200 w-12"></div>
</div>
))}
</div>
</div>
)
case 'type8':
return (
<div className="w-full h-[120px] bg-white p-3 flex gap-2">
<div className="w-1/2 space-y-1.5">
<div className="h-3 bg-gray-200 w-3/4"></div>
<div className="h-2 bg-gray-100 w-full"></div>
</div>
<div className="w-1/2 space-y-2">
{[1, 2].map((i) => (
<div key={i} className="flex gap-1.5 bg-gray-50 p-1.5">
<div className="w-6 h-6 bg-gray-200 flex-shrink-0 flex items-center justify-center">
<p className='text-gray-500 text-[10px]'>Icon</p>
</div>
<div className="flex-1">
<div className="h-1.5 bg-gray-200 w-3/4"></div>
</div>
</div>
))}
</div>
</div>
)
case 'type9':
return (
<div className="w-full h-[120px] bg-white p-3 flex gap-2">
<div className="w-1/2 space-y-1.5">
<div className="h-2 bg-gray-200 w-3/4"></div>
<div className="flex-1 bg-gray-100 h-full flex items-center justify-center">
<p className='text-gray-500 text-sm'>chart</p>
</div>
</div>
<div className="w-1/2 space-y-1.5">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-1.5 bg-gray-50 p-1.5">
<div className="w-4 h-4 bg-gray-200 rounded-full flex-shrink-0"></div>
<div className="flex-1">
<div className="h-1.5 bg-gray-200 w-3/4"></div>
</div>
</div>
))}
</div>
</div>
)
default:
return null
const NewSlide = ({ setShowNewSlideSelection, group, index, presentationId }: NewSlideProps) => {
const dispatch = useDispatch();
const handleNewSlide = (sampleData: any, id: string) => {
const newSlide = {
id: crypto.randomUUID(),
index: index,
content: sampleData,
layout_group: group,
layout: `${group}:${id}`,
presentation: presentationId
}
dispatch(addNewSlide({ slideData: newSlide, index }));
setShowNewSlideSelection(false);
}
const { layoutGroup, loading } = useGroupLayoutLoader(group)
if (loading) {
return (
<div className='my-6 w-full bg-gray-50 p-8 max-w-[1280px]'>
<div className='flex justify-between items-center mb-8'>
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<Trash2 onClick={() => setShowNewSlideSelection(false)} className='text-gray-500 text-2xl cursor-pointer' />
</div>
<div className='flex items-center justify-center h-32'>
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
</div>
)
}
}
const NewSlide = ({ onSelectLayout, setShowNewSlideSelection }: NewSlideProps) => {
return (
<div className='my-6 w-full bg-gray-50 p-8 max-w-[1280px]'>
<div className='flex justify-between items-center mb-8'>
@ -155,22 +49,17 @@ const NewSlide = ({ onSelectLayout, setShowNewSlideSelection }: NewSlideProps) =
<Trash2 onClick={() => setShowNewSlideSelection(false)} className='text-gray-500 text-2xl cursor-pointer' />
</div>
<div className='grid grid-cols-4 gap-4'>
{['type1', 'type2', 'type4', 'type5', 'type6', 'type7', 'type8', 'type9'].map((type) => (
<div
key={type}
className="transform hover:scale-105 transition-transform cursor-pointer"
onClick={() => onSelectLayout(parseInt(type.replace('type', '')))}
>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="p-2 border-b">
<h3 className="text-xs font-medium">Layout {type.replace('type', '')}</h3>
</div>
<div className="p-2">
<LayoutPreview type={type} />
{layoutGroup && layoutGroup?.layouts.map((layout: any, index: number) => {
const { component: LayoutComponent, sampleData, layoutId } = layout
return (
<div onClick={() => handleNewSlide(sampleData, layoutId)} key={`${layoutGroup?.groupName}-${index}`} className=" relative cursor-pointer overflow-hidden aspect-video">
<div className="absolute cursor-pointer bg-transparent z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
<LayoutComponent data={sampleData} />
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)

View file

@ -35,7 +35,7 @@ export const useGroupLayouts = () => {
// Render slide content with group validation, automatic Tiptap text editing, and editable images/icons
const renderSlideContent = useMemo(() => {
return (slide: any, isEditMode: boolean = true) => {
return (slide: any, isEditMode: boolean) => {
const Layout = getGroupLayout(slide.layout, slide.layout_group);
if (!Layout) {
return (
@ -55,6 +55,7 @@ export const useGroupLayouts = () => {
isEditMode={isEditMode}
>
<TiptapTextReplacer
key={slide.id}
slideData={slide.content}
slideIndex={slide.index}
isEditMode={isEditMode}

View file

@ -43,7 +43,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
});
// Custom hooks
const { fetchUserSlides, handleDeleteSlide } = usePresentationData(
const { fetchUserSlides } = usePresentationData(
presentation_id,
setLoading,
setError
@ -72,9 +72,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
fetchUserSlides
);
const onDeleteSlide = (index: number) => {
handleDeleteSlide(index, presentationData);
};
const onSlideChange = (newSlide: number) => {
handleSlideChange(newSlide, presentationData);
@ -172,7 +170,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({ presentation_id })
slide={slide}
index={index}
presentationId={presentation_id}
onDeleteSlide={onDeleteSlide}
/>
))}
</>

View file

@ -13,23 +13,21 @@ import { PresentationGenerationApi } from "../../services/api/presentation-gener
import ToolTip from "@/components/ToolTip";
import { RootState } from "@/store/store";
import { useDispatch, useSelector } from "react-redux";
import { addSlide, updateSlide } from "@/store/slices/presentationGeneration";
import { deletePresentationSlide, updateSlide } from "@/store/slices/presentationGeneration";
import NewSlide from "../../components/slide_layouts/NewSlide";
import { getEmptySlideContent } from "../../utils/NewSlideContent";
import { useGroupLayouts } from "../../hooks/useGroupLayouts";
interface SlideContentProps {
slide: any;
index: number;
presentationId: string;
onDeleteSlide: (index: number) => void;
}
const SlideContent = ({
slide,
index,
presentationId,
onDeleteSlide,
}: SlideContentProps) => {
const dispatch = useDispatch();
const [isUpdating, setIsUpdating] = useState(false);
@ -82,29 +80,16 @@ const SlideContent = ({
setIsUpdating(false);
}
};
const handleNewSlide = (type: number, index: number) => {
const newSlide: Slide = getEmptySlideContent(
type,
index + 1,
presentationData?.id!
);
dispatch(addSlide({ slide: newSlide, index: index + 1 }));
setShowNewSlideSelection(false);
// Scroll to the newly added slide after a short delay to ensure it's rendered
setTimeout(() => {
const newSlideElement = document.getElementById(`slide-${newSlide.id}`);
if (newSlideElement) {
newSlideElement.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, 100);
const onDeleteSlide = async () => {
try {
dispatch(deletePresentationSlide(slide.index));
} catch (error) {
console.error("Error deleting slide:", error);
}
};
// Scroll to the new slide when streaming and new slides are being generated
useEffect(() => {
if (
@ -139,16 +124,16 @@ const SlideContent = ({
{isStreaming && (
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
)}
<div data-layout={slide.layout} data-group={slide.layout_group} className={` w-full group mb-6`}>
<div data-layout={slide.layout} data-group={slide.layout_group} className={` w-full group `}>
{/* render slides */}
{loading ? <div className="flex flex-col bg-white aspect-video items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" />
</div> : slideContent}
{/* {!showNewSlideSelection && (
{!showNewSlideSelection && (
<div className="group-hover:opacity-100 hidden md:block opacity-0 transition-opacity my-4 duration-300">
<ToolTip content="Add new slide below">
{!isStreaming && (
{!isStreaming && !loading && (
<div
onClick={() => setShowNewSlideSelection(true)}
className=" bg-white shadow-md w-[80px] py-2 border hover:border-[#5141e5] duration-300 flex items-center justify-center rounded-lg cursor-pointer mx-auto"
@ -159,16 +144,18 @@ const SlideContent = ({
</ToolTip>
</div>
)}
{showNewSlideSelection && (
{showNewSlideSelection && !loading && (
<NewSlide
onSelectLayout={(type) => handleNewSlide(type, slide.index)}
index={index}
group={slide.layout_group}
setShowNewSlideSelection={setShowNewSlideSelection}
presentationId={presentationId}
/>
)} */}
{!isStreaming && (
)}
{!isStreaming && !loading && (
<ToolTip content="Delete slide">
<div
onClick={() => onDeleteSlide(slide.index)}
onClick={onDeleteSlide}
className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform"
>
<Trash2 className="text-gray-500 text-xl cursor-pointer" />

View file

@ -2,8 +2,7 @@ import { useCallback } from "react";
import { useDispatch } from "react-redux";
import { toast } from "@/hooks/use-toast";
import { DashboardApi } from "@/app/dashboard/api/dashboard";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { setPresentationData, deletePresentationSlide } from "@/store/slices/presentationGeneration";
import { setPresentationData } from "@/store/slices/presentationGeneration";
export const usePresentationData = (
presentationId: string,
@ -32,25 +31,10 @@ export const usePresentationData = (
}
}, [presentationId, dispatch, setLoading, setError]);
const handleDeleteSlide = useCallback(async (index: number, presentationData: any) => {
dispatch(deletePresentationSlide(index));
try {
await PresentationGenerationApi.deleteSlide(
presentationId,
presentationData?.slides[index].id!
);
} catch (error) {
console.error("Error deleting slide:", error);
toast({
title: "Error",
description: "Failed to delete slide",
variant: "destructive",
});
}
}, [presentationId, dispatch]);
return {
fetchUserSlides,
handleDeleteSlide,
};
};

View file

@ -63,11 +63,6 @@ export const PresentationCard = ({
});
}
};
return (
<Card
onClick={handlePreview}
@ -82,11 +77,11 @@ export const PresentationCard = ({
{new Date(created_at).toLocaleDateString()}
</p>
<Popover>
<PopoverTrigger onClick={(e) => e.stopPropagation()}>
<button className="w-6 h-6 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" >
<PopoverTrigger className="w-6 h-6 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
<DotsVerticalIcon className="w-4 h-4 text-gray-500" />
<DotsVerticalIcon className="w-4 h-4 text-gray-500" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="bg-white w-[200px]">
<button

View file

@ -70,7 +70,7 @@ const GroupLayoutPreview = () => {
{/* Layout Grid */}
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="space-y-8">
{layoutGroup.layouts.map((layout, index) => {
{layoutGroup.layouts.map((layout: any, index: number) => {
const { component: LayoutComponent, sampleData, name, fileName } = layout
return (

View file

@ -1,5 +1,5 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { LayoutInfo, LayoutGroup, GroupedLayoutsResponse, GroupSetting } from '../types'
import { toast } from '@/hooks/use-toast'
@ -11,16 +11,34 @@ interface UseGroupLayoutLoaderReturn {
retry: () => void
}
// Global cache to store layout groups and avoid re-fetching
const layoutGroupCache = new Map<string, LayoutGroup>()
const loadingGroupsCache = new Set<string>()
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 hasMountedRef = useRef(false)
const loadGroupLayouts = async () => {
// Check cache first
if (layoutGroupCache.has(groupSlug)) {
setLayoutGroup(layoutGroupCache.get(groupSlug)!)
setLoading(false)
setError(null)
return
}
// Prevent multiple simultaneous requests for the same group
if (loadingGroupsCache.has(groupSlug)) {
return
}
try {
setLoading(true)
setError(null)
setLayoutGroup(null)
loadingGroupsCache.add(groupSlug)
const response = await fetch('/api/layouts')
if (!response.ok) {
@ -76,6 +94,7 @@ export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderRet
// Use empty object to let schema apply its default values
const sampleData = module.Schema.parse({})
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
const layoutInfo: LayoutInfo = {
name: layoutName,
@ -83,7 +102,8 @@ export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderRet
schema: module.Schema,
sampleData,
fileName,
groupName: targetGroupData.groupName
groupName: targetGroupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
@ -98,13 +118,15 @@ export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderRet
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: targetGroupData.groupName
groupName: targetGroupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
} else {
@ -123,11 +145,15 @@ export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderRet
})
setError(`No valid layouts found in "${groupSlug}" group.`)
} else {
setLayoutGroup({
const group: LayoutGroup = {
groupName: targetGroupData.groupName,
layouts: groupLayouts,
settings: groupSettings
})
}
// Cache the result
layoutGroupCache.set(groupSlug, group)
setLayoutGroup(group)
setError(null)
}
@ -136,15 +162,19 @@ export const useGroupLayoutLoader = (groupSlug: string): UseGroupLayoutLoaderRet
setError(error instanceof Error ? error.message : 'Failed to load group layouts')
} finally {
setLoading(false)
loadingGroupsCache.delete(groupSlug)
}
}
const retry = () => {
// Clear cache for this group to force reload
layoutGroupCache.delete(groupSlug)
loadGroupLayouts()
}
useEffect(() => {
if (groupSlug) {
if (groupSlug && !hasMountedRef.current) {
hasMountedRef.current = true
loadGroupLayouts()
}
}, [groupSlug])

View file

@ -73,6 +73,7 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
// Use empty object to let schema apply its default values
// User will need to provide actual data when using the layouts
const sampleData = module.Schema.parse({})
const layoutId = module.layoutId || layoutName.toLowerCase().replace(/layout$/, '')
const layoutInfo: LayoutInfo = {
name: layoutName,
@ -80,7 +81,8 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
schema: module.Schema,
sampleData,
fileName,
groupName: groupData.groupName
groupName: groupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
@ -97,13 +99,15 @@ export const useLayoutLoader = (): UseLayoutLoaderReturn => {
if (module.default && module.Schema) {
// Use empty object to let schema apply its default values
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
groupName: groupData.groupName,
layoutId
}
groupLayouts.push(layoutInfo)
allLayouts.push(layoutInfo)

View file

@ -6,6 +6,7 @@ export interface LayoutInfo {
sampleData: any
fileName: string
groupName: string
layoutId: string
}
export interface GroupSetting {

View file

@ -193,6 +193,21 @@ const presentationGenerationSlice = createSlice({
}
},
addNewSlide: (state, action: PayloadAction<{ slideData: any; index: number }>) => {
if (state.presentationData?.slides) {
// Insert the new slide at the specified index + 1 (after current slide)
state.presentationData.slides.splice(action.payload.index +1, 0, action.payload.slideData);
// Update indices for all slides to ensure they remain sequential
state.presentationData.slides = state.presentationData.slides.map(
(slide: any, idx: number) => ({
...slide,
index: idx,
})
);
}
},
// Update slide image at specific data path
updateSlideImage: (
state,
@ -365,6 +380,7 @@ export const {
updateSlideContent,
updateSlideImage,
updateSlideIcon,
addNewSlide,
} = presentationGenerationSlice.actions;
export default presentationGenerationSlice.reducer;