From ddf05e6259c018552fc0f8cb54e2c97639a61d8c Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Fri, 26 Dec 2025 21:42:56 +0100 Subject: [PATCH] Add page deletion functionality with confirmation dialog and API endpoin --- app/api/delete-page/route.ts | 66 ++++++ .../[storySlug]/story-editor-client.tsx | 190 +++++++++++++----- components/editor/comic-canvas.tsx | 17 +- components/editor/generate-page-modal.tsx | 95 +++++---- components/editor/page-sidebar.tsx | 2 +- lib/db-actions.ts | 4 + 6 files changed, 281 insertions(+), 93 deletions(-) create mode 100644 app/api/delete-page/route.ts diff --git a/app/api/delete-page/route.ts b/app/api/delete-page/route.ts new file mode 100644 index 0000000..9deaca0 --- /dev/null +++ b/app/api/delete-page/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { getStoryWithPagesBySlug, deletePage } from "@/lib/db-actions"; + +export async function DELETE(request: NextRequest) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 } + ); + } + + const { storySlug, pageId } = await request.json(); + + if (!storySlug || !pageId) { + return NextResponse.json( + { error: "Missing required fields: storySlug and pageId" }, + { status: 400 } + ); + } + + // Get the story to check ownership + const storyData = await getStoryWithPagesBySlug(storySlug); + if (!storyData) { + return NextResponse.json({ error: "Story not found" }, { status: 404 }); + } + + const { story, pages } = storyData; + + // Check ownership + if (story.userId !== userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + // Check if page exists and belongs to the story + const pageExists = pages.some(p => p.id === pageId); + if (!pageExists) { + return NextResponse.json({ error: "Page not found" }, { status: 404 }); + } + + // Don't allow deleting the last page + if (pages.length <= 1) { + return NextResponse.json( + { error: "Cannot delete the last page of a story" }, + { status: 400 } + ); + } + + await deletePage(pageId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting page:", error); + return NextResponse.json( + { + error: `Internal server error: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/editor/[storySlug]/story-editor-client.tsx b/app/editor/[storySlug]/story-editor-client.tsx index 56ad6f2..4b8cee4 100644 --- a/app/editor/[storySlug]/story-editor-client.tsx +++ b/app/editor/[storySlug]/story-editor-client.tsx @@ -11,6 +11,16 @@ import { ApiKeyModal } from "@/components/api-key-modal"; import { PageInfoSheet } from "@/components/editor/page-info-sheet"; import { GeneratePageModal } from "@/components/editor/generate-page-modal"; import { StoryLoader } from "@/components/ui/story-loader"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; interface PageData { id: number; // pageNumber for component compatibility @@ -43,6 +53,8 @@ export function StoryEditorClient() { const [showApiModal, setShowApiModal] = useState(false); const [showInfoSheet, setShowInfoSheet] = useState(false); const [showGenerateModal, setShowGenerateModal] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [pageToDelete, setPageToDelete] = useState(null); const [loadingPageId, setLoadingPageId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [existingCharacterImages, setExistingCharacterImages] = useState< @@ -207,6 +219,67 @@ export function StoryEditorClient() { setShowApiModal(true); }; + const handleDeletePage = (pageIndex: number) => { + setPageToDelete(pageIndex); + setShowDeleteDialog(true); + }; + + const confirmDeletePage = async () => { + if (pageToDelete === null) return; + + const pageData = pages[pageToDelete]; + if (!pageData) return; + + setShowDeleteDialog(false); + + try { + const response = await fetch("/api/delete-page", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + storySlug: story?.slug, + pageId: pageData.dbId, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to delete page"); + } + + // Remove the page from state + setPages((prevPages) => { + const newPages = prevPages.filter((_, index) => index !== pageToDelete); + // Adjust currentPage if necessary + if (currentPage >= newPages.length) { + setCurrentPage(Math.max(0, newPages.length - 1)); + } else if (currentPage > pageToDelete) { + setCurrentPage(currentPage - 1); + } + return newPages; + }); + + toast({ + title: "Page deleted successfully", + description: "The page has been removed from your comic.", + duration: 3000, + }); + } catch (error) { + console.error("Error deleting page:", error); + toast({ + title: "Failed to delete page", + description: + error instanceof Error ? error.message : "Failed to delete page", + variant: "destructive", + duration: 4000, + }); + } finally { + setPageToDelete(null); + } + }; + const handleApiKeySubmit = (key: string) => { setApiKey(key); setShowApiModal(false); @@ -218,61 +291,49 @@ export function StoryEditorClient() { const handleGeneratePage = async (data: { prompt: string; - characterFiles?: File[]; characterUrls?: string[]; - }) => { - try { - if (!apiKey) { - setShowApiModal(true); - return; - } - - // Add new page mode - const response = await fetch("/api/add-page", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - }, - body: JSON.stringify({ - storyId: story?.slug, - prompt: data.prompt, - characterImages: data.characterUrls || [], - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to generate page"); - } - - const result = await response.json(); - - setPages((prevPages) => [ - ...prevPages, - { - id: pages.length + 1, - title: story?.title || "", - image: result.imageUrl, - prompt: data.prompt, - characterUploads: data.characterUrls || [], - style: story?.style || "noir", - dbId: result.pageId, - }, - ]); - setCurrentPage(pages.length); - - setShowGenerateModal(false); - } catch (error) { - console.error("Error generating page:", error); - toast({ - title: "Failed to generate page", - description: - error instanceof Error ? error.message : "Failed to generate page", - variant: "destructive", - duration: 4000, - }); + }): Promise => { + if (!apiKey) { + setShowApiModal(true); + throw new Error("API key required"); } + + // Add new page mode + const response = await fetch("/api/add-page", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + storyId: story?.slug, + prompt: data.prompt, + characterImages: data.characterUrls || [], + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate page"); + } + + const result = await response.json(); + + setPages((prevPages) => [ + ...prevPages, + { + id: pages.length + 1, + title: story?.title || "", + image: result.imageUrl, + prompt: data.prompt, + characterUploads: data.characterUrls || [], + style: story?.style || "noir", + dbId: result.pageId, + }, + ]); + setCurrentPage(pages.length); + + setShowGenerateModal(false); }; if (isLoading) { @@ -312,10 +373,12 @@ export function StoryEditorClient() { setShowInfoSheet(true)} onRedrawClick={handleRedrawPage} + onDeletePage={() => handleDeletePage(currentPage)} onNextPage={() => setCurrentPage((prev) => prev < pages.length - 1 ? prev + 1 : prev @@ -343,6 +406,27 @@ export function StoryEditorClient() { onClose={() => setShowInfoSheet(false)} page={pages[currentPage]} /> + + + + + Delete Page + + Are you sure you want to delete page {pageToDelete !== null ? pageToDelete + 1 : ""}? + This action cannot be undone. + + + + Cancel + + Delete + + + + ); } diff --git a/components/editor/comic-canvas.tsx b/components/editor/comic-canvas.tsx index 97c5b0f..e2aa080 100644 --- a/components/editor/comic-canvas.tsx +++ b/components/editor/comic-canvas.tsx @@ -1,6 +1,6 @@ "use client"; -import { RefreshCw, Share, Info, Loader2 } from "lucide-react"; +import { RefreshCw, Share, Info, Loader2, Trash2 } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { Button } from "@/components/ui/button"; @@ -17,10 +17,12 @@ interface PageData { interface ComicCanvasProps { page: PageData; pageIndex: number; + totalPages?: number; isLoading?: boolean; isOwner?: boolean; onInfoClick?: () => void; onRedrawClick?: () => void; + onDeletePage?: () => void; onNextPage?: () => void; onPrevPage?: () => void; } @@ -28,10 +30,12 @@ interface ComicCanvasProps { export function ComicCanvas({ page, pageIndex, + totalPages = 1, isLoading = false, isOwner = true, onInfoClick, onRedrawClick, + onDeletePage, onNextPage, onPrevPage, }: ComicCanvasProps) { @@ -105,6 +109,17 @@ export function ComicCanvas({ {isLoading ? "Redrawing..." : "Redraw"} )} + + {isOwner && totalPages > 1 && onDeletePage && ( + + )}
diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx index 29b534c..c987f4f 100644 --- a/components/editor/generate-page-modal.tsx +++ b/components/editor/generate-page-modal.tsx @@ -13,15 +13,15 @@ import { import { useToast } from "@/hooks/use-toast"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"; +import { useS3Upload } from "next-s3-upload"; interface GeneratePageModalProps { isOpen: boolean; onClose: () => void; onGenerate: (data: { prompt: string; - characterFiles?: File[]; characterUrls?: string[]; - }) => void; + }) => Promise; pageNumber: number; isRedrawMode?: boolean; existingPrompt?: string; @@ -42,6 +42,7 @@ export function GeneratePageModal({ const [isGenerating, setIsGenerating] = useState(false); const fileInputRef = useRef(null); const { toast } = useToast(); + const { uploadToS3 } = useS3Upload(); // Reset form when modal opens useEffect(() => { @@ -55,11 +56,14 @@ export function GeneratePageModal({ }, [isOpen, isRedrawMode, existingPrompt]); // Keyboard shortcut for form submission (disabled during generation) - useKeyboardShortcut(() => { - if (isOpen && !isGenerating && prompt.trim()) { - handleGenerate(); - } - }, { disabled: !isOpen || isGenerating }); + useKeyboardShortcut( + () => { + if (isOpen && !isGenerating && prompt.trim()) { + handleGenerate(); + } + }, + { disabled: !isOpen || isGenerating } + ); const handleFiles = async (newFiles: FileList | null) => { if (!newFiles) return; @@ -112,30 +116,31 @@ export function GeneratePageModal({ if (!prompt.trim()) return; setIsGenerating(true); - const fileDataUrls = await Promise.all( - uploadedFiles.map((file) => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => { - const base64 = e.target?.result as string; - const [header, base64Data] = base64.split(","); - // Send just the base64 data without the data URL prefix - resolve(base64Data); - }; - reader.readAsDataURL(file); - }); - }) - ); + try { + // Upload files to S3 and get URLs + const characterUrls = await Promise.all( + uploadedFiles.map((file) => uploadToS3(file).then(({ url }) => url)) + ); - onGenerate({ - prompt, - characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - characterUrls: fileDataUrls, - }); + await onGenerate({ + prompt, + characterUrls: characterUrls.length > 0 ? characterUrls : undefined, + }); + } catch (error) { + console.error("Error generating page:", error); + toast({ + title: "Generation failed", + description: + error instanceof Error + ? error.message + : "Failed to generate page. Please try again.", + variant: "destructive", + duration: 4000, + }); + setIsGenerating(false); + } }; - - const handleOpenChange = (open: boolean) => { // Prevent closing the modal if generation is running if (!open && isGenerating) { @@ -150,9 +155,14 @@ export function GeneratePageModal({ - {isRedrawMode ? `Redraw Page ${pageNumber}` : `Generate Page ${pageNumber}`} + {isRedrawMode + ? `Redraw Page ${pageNumber}` + : `Generate Page ${pageNumber}`} - + Close @@ -171,7 +181,11 @@ export function GeneratePageModal({