Add page deletion functionality with confirmation dialog and API endpoin

This commit is contained in:
Riccardo Giorato
2025-12-26 21:42:56 +01:00
parent d8f0046c14
commit ddf05e6259
6 changed files with 281 additions and 93 deletions
+66
View File
@@ -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 }
);
}
}
+137 -53
View File
@@ -11,6 +11,16 @@ import { ApiKeyModal } from "@/components/api-key-modal";
import { PageInfoSheet } from "@/components/editor/page-info-sheet"; import { PageInfoSheet } from "@/components/editor/page-info-sheet";
import { GeneratePageModal } from "@/components/editor/generate-page-modal"; import { GeneratePageModal } from "@/components/editor/generate-page-modal";
import { StoryLoader } from "@/components/ui/story-loader"; import { StoryLoader } from "@/components/ui/story-loader";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface PageData { interface PageData {
id: number; // pageNumber for component compatibility id: number; // pageNumber for component compatibility
@@ -43,6 +53,8 @@ export function StoryEditorClient() {
const [showApiModal, setShowApiModal] = useState(false); const [showApiModal, setShowApiModal] = useState(false);
const [showInfoSheet, setShowInfoSheet] = useState(false); const [showInfoSheet, setShowInfoSheet] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false); const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [pageToDelete, setPageToDelete] = useState<number | null>(null);
const [loadingPageId, setLoadingPageId] = useState<number | null>(null); const [loadingPageId, setLoadingPageId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [existingCharacterImages, setExistingCharacterImages] = useState< const [existingCharacterImages, setExistingCharacterImages] = useState<
@@ -207,6 +219,67 @@ export function StoryEditorClient() {
setShowApiModal(true); 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) => { const handleApiKeySubmit = (key: string) => {
setApiKey(key); setApiKey(key);
setShowApiModal(false); setShowApiModal(false);
@@ -218,61 +291,49 @@ export function StoryEditorClient() {
const handleGeneratePage = async (data: { const handleGeneratePage = async (data: {
prompt: string; prompt: string;
characterFiles?: File[];
characterUrls?: string[]; characterUrls?: string[];
}) => { }): Promise<void> => {
try { if (!apiKey) {
if (!apiKey) { setShowApiModal(true);
setShowApiModal(true); throw new Error("API key required");
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,
});
} }
// 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) { if (isLoading) {
@@ -312,10 +373,12 @@ export function StoryEditorClient() {
<ComicCanvas <ComicCanvas
page={pages[currentPage]} page={pages[currentPage]}
pageIndex={currentPage} pageIndex={currentPage}
totalPages={pages.length}
isLoading={loadingPageId === currentPage} isLoading={loadingPageId === currentPage}
isOwner={isOwner} isOwner={isOwner}
onInfoClick={() => setShowInfoSheet(true)} onInfoClick={() => setShowInfoSheet(true)}
onRedrawClick={handleRedrawPage} onRedrawClick={handleRedrawPage}
onDeletePage={() => handleDeletePage(currentPage)}
onNextPage={() => onNextPage={() =>
setCurrentPage((prev) => setCurrentPage((prev) =>
prev < pages.length - 1 ? prev + 1 : prev prev < pages.length - 1 ? prev + 1 : prev
@@ -343,6 +406,27 @@ export function StoryEditorClient() {
onClose={() => setShowInfoSheet(false)} onClose={() => setShowInfoSheet(false)}
page={pages[currentPage]} page={pages[currentPage]}
/> />
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Page</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete page {pageToDelete !== null ? pageToDelete + 1 : ""}?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeletePage}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
+16 -1
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -17,10 +17,12 @@ interface PageData {
interface ComicCanvasProps { interface ComicCanvasProps {
page: PageData; page: PageData;
pageIndex: number; pageIndex: number;
totalPages?: number;
isLoading?: boolean; isLoading?: boolean;
isOwner?: boolean; isOwner?: boolean;
onInfoClick?: () => void; onInfoClick?: () => void;
onRedrawClick?: () => void; onRedrawClick?: () => void;
onDeletePage?: () => void;
onNextPage?: () => void; onNextPage?: () => void;
onPrevPage?: () => void; onPrevPage?: () => void;
} }
@@ -28,10 +30,12 @@ interface ComicCanvasProps {
export function ComicCanvas({ export function ComicCanvas({
page, page,
pageIndex, pageIndex,
totalPages = 1,
isLoading = false, isLoading = false,
isOwner = true, isOwner = true,
onInfoClick, onInfoClick,
onRedrawClick, onRedrawClick,
onDeletePage,
onNextPage, onNextPage,
onPrevPage, onPrevPage,
}: ComicCanvasProps) { }: ComicCanvasProps) {
@@ -105,6 +109,17 @@ export function ComicCanvas({
<span>{isLoading ? "Redrawing..." : "Redraw"}</span> <span>{isLoading ? "Redrawing..." : "Redraw"}</span>
</Button> </Button>
)} )}
{isOwner && totalPages > 1 && onDeletePage && (
<Button
variant="ghost"
className="hover:bg-red-600/20 text-muted-foreground hover:text-red-400 gap-2 text-xs h-9 px-3"
onClick={onDeletePage}
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</Button>
)}
</div> </div>
<div className="flex flex-col items-center gap-3 mt-4"> <div className="flex flex-col items-center gap-3 mt-4">
+57 -38
View File
@@ -13,15 +13,15 @@ import {
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"; import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
import { useS3Upload } from "next-s3-upload";
interface GeneratePageModalProps { interface GeneratePageModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onGenerate: (data: { onGenerate: (data: {
prompt: string; prompt: string;
characterFiles?: File[];
characterUrls?: string[]; characterUrls?: string[];
}) => void; }) => Promise<void>;
pageNumber: number; pageNumber: number;
isRedrawMode?: boolean; isRedrawMode?: boolean;
existingPrompt?: string; existingPrompt?: string;
@@ -42,6 +42,7 @@ export function GeneratePageModal({
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast(); const { toast } = useToast();
const { uploadToS3 } = useS3Upload();
// Reset form when modal opens // Reset form when modal opens
useEffect(() => { useEffect(() => {
@@ -55,11 +56,14 @@ export function GeneratePageModal({
}, [isOpen, isRedrawMode, existingPrompt]); }, [isOpen, isRedrawMode, existingPrompt]);
// Keyboard shortcut for form submission (disabled during generation) // Keyboard shortcut for form submission (disabled during generation)
useKeyboardShortcut(() => { useKeyboardShortcut(
if (isOpen && !isGenerating && prompt.trim()) { () => {
handleGenerate(); if (isOpen && !isGenerating && prompt.trim()) {
} handleGenerate();
}, { disabled: !isOpen || isGenerating }); }
},
{ disabled: !isOpen || isGenerating }
);
const handleFiles = async (newFiles: FileList | null) => { const handleFiles = async (newFiles: FileList | null) => {
if (!newFiles) return; if (!newFiles) return;
@@ -112,30 +116,31 @@ export function GeneratePageModal({
if (!prompt.trim()) return; if (!prompt.trim()) return;
setIsGenerating(true); setIsGenerating(true);
const fileDataUrls = await Promise.all( try {
uploadedFiles.map((file) => { // Upload files to S3 and get URLs
return new Promise<string>((resolve) => { const characterUrls = await Promise.all(
const reader = new FileReader(); uploadedFiles.map((file) => uploadToS3(file).then(({ url }) => url))
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);
});
})
);
onGenerate({ await onGenerate({
prompt, prompt,
characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, characterUrls: characterUrls.length > 0 ? characterUrls : undefined,
characterUrls: fileDataUrls, });
}); } 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) => { const handleOpenChange = (open: boolean) => {
// Prevent closing the modal if generation is running // Prevent closing the modal if generation is running
if (!open && isGenerating) { if (!open && isGenerating) {
@@ -150,9 +155,14 @@ export function GeneratePageModal({
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-lg"> <DialogContent className="border border-border/50 rounded-lg bg-background max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl text-white font-heading"> <DialogTitle className="text-xl text-white font-heading">
{isRedrawMode ? `Redraw Page ${pageNumber}` : `Generate Page ${pageNumber}`} {isRedrawMode
? `Redraw Page ${pageNumber}`
: `Generate Page ${pageNumber}`}
</DialogTitle> </DialogTitle>
<DialogClose disabled={isGenerating} className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogClose
disabled={isGenerating}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogClose> </DialogClose>
@@ -171,7 +181,11 @@ export function GeneratePageModal({
<textarea <textarea
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={isRedrawMode ? "Tweak the prompt to improve this page..." : "Continue the story... Describe what happens next."} placeholder={
isRedrawMode
? "Tweak the prompt to improve this page..."
: "Continue the story... Describe what happens next."
}
disabled={isGenerating} disabled={isGenerating}
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight" className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
/> />
@@ -196,8 +210,8 @@ export function GeneratePageModal({
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
removeFile(index) removeFile(index);
}} }}
disabled={isGenerating} disabled={isGenerating}
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity disabled:opacity-50" className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity disabled:opacity-50"
@@ -208,7 +222,9 @@ export function GeneratePageModal({
))} ))}
{uploadedFiles.length < 2 && ( {uploadedFiles.length < 2 && (
<button <button
onClick={() => !isGenerating && fileInputRef.current?.click()} onClick={() =>
!isGenerating && fileInputRef.current?.click()
}
disabled={isGenerating} disabled={isGenerating}
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
@@ -218,7 +234,9 @@ export function GeneratePageModal({
</div> </div>
) : ( ) : (
<button <button
onClick={() => !isGenerating && fileInputRef.current?.click()} onClick={() =>
!isGenerating && fileInputRef.current?.click()
}
disabled={isGenerating} disabled={isGenerating}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
@@ -244,8 +262,7 @@ export function GeneratePageModal({
<div className="text-xs text-muted-foreground/70"> <div className="text-xs text-muted-foreground/70">
{isRedrawMode {isRedrawMode
? "This will replace the current page with a new version. Previous pages and characters are automatically referenced." ? "This will replace the current page with a new version. Previous pages and characters are automatically referenced."
: "Automatically references previous pages and existing characters from your story." : "Automatically references previous pages and existing characters from your story."}
}
</div> </div>
<Button <Button
@@ -256,7 +273,9 @@ export function GeneratePageModal({
{isGenerating ? ( {isGenerating ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span>{isRedrawMode ? "Redrawing page..." : "Generating page..."}</span> <span>
{isRedrawMode ? "Redrawing page..." : "Generating page..."}
</span>
</> </>
) : ( ) : (
`${isRedrawMode ? "Redraw" : "Generate"} Page ${pageNumber}` `${isRedrawMode ? "Redraw" : "Generate"} Page ${pageNumber}`
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Plus, Loader2, Key } from "lucide-react"; import { Plus, Loader2, Key, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserButton, SignedIn } from "@clerk/nextjs"; import { UserButton, SignedIn } from "@clerk/nextjs";
+4
View File
@@ -141,4 +141,8 @@ export async function getNextPageNumber(storyId: string): Promise<number> {
} }
return Math.max(...storyPages.map(p => p.pageNumber)) + 1; return Math.max(...storyPages.map(p => p.pageNumber)) + 1;
}
export async function deletePage(pageId: string): Promise<void> {
await db.delete(pages).where(eq(pages.id, pageId));
} }