"use client"; import { useState, useEffect } from "react"; import { useParams } from "next/navigation"; import { useToast } from "@/hooks/use-toast"; import { useApiKey } from "@/hooks/use-api-key"; import { useAuth } from "@clerk/nextjs"; import { EditorToolbar } from "@/components/editor/editor-toolbar"; import { PageSidebar } from "@/components/editor/page-sidebar"; import { ComicCanvas } from "@/components/editor/comic-canvas"; 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 title: string; image: string; prompt: string; characterUploads?: string[]; style: string; dbId?: string; // actual database UUID } interface StoryData { id: string; slug: string; title: string; description?: string | null; style: string; userId?: string | null; isOwner?: boolean; } export function StoryEditorClient() { const params = useParams(); const slug = params.storySlug as string; const { isSignedIn, isLoaded } = useAuth(); const [story, setStory] = useState(null); const [isOwner, setIsOwner] = useState(false); const [pages, setPages] = useState([]); const [currentPage, setCurrentPage] = useState(0); 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 [showRedrawDialog, setShowRedrawDialog] = useState(false); const [loadingPageId, setLoadingPageId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [existingCharacterImages, setExistingCharacterImages] = useState< string[] >([]); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const { toast } = useToast(); const [apiKey, setApiKey] = useApiKey(); const handleTitleUpdate = (newTitle: string) => { setStory(prev => prev ? { ...prev, title: newTitle } : null); }; // Load story and pages from API useEffect(() => { const loadStoryData = async () => { try { const response = await fetch(`/api/stories/${slug}`); if (!response.ok) { throw new Error("Story not found"); } const result = await response.json(); const { story: storyData, pages: pagesData, isOwner: ownerStatus, } = result; setStory(storyData); setIsOwner(ownerStatus ?? false); // Default to false if undefined setPages( pagesData.map((page: any) => ({ id: page.pageNumber, title: storyData.title, image: page.generatedImageUrl || "", prompt: page.prompt, characterUploads: page.characterImageUrls, style: storyData.style || "noir", dbId: page.id, })) ); // Load existing character images for reuse const uniqueImages = [ ...new Set( pagesData.flatMap((page: any) => page.characterImageUrls || []) ), ]; setExistingCharacterImages(uniqueImages as string[]); } catch (error) { console.error("Error loading story:", error); toast({ title: "Error loading story", description: "Failed to load story data.", variant: "destructive", duration: 4000, }); } finally { setIsLoading(false); } }; if (slug) { loadStoryData(); } }, [slug, toast]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't trigger shortcuts if user is typing in an input field const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) { return; } if (e.key === "ArrowRight") { e.preventDefault(); setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)); } else if (e.key === "ArrowLeft") { e.preventDefault(); setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)); } else if (e.key === "ArrowUp") { e.preventDefault(); setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)); } else if (e.key === "ArrowDown") { e.preventDefault(); setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)); } else if (e.key === "i" || e.key === "I") { e.preventDefault(); setShowInfoSheet(true); } else if (e.key === "c" || e.key === "C") { e.preventDefault(); handleAddPage(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [pages.length, apiKey]); const handleAddPage = async () => { if (!isLoaded || !isSignedIn) { return; } // Check credits try { const hasApiKey = !!apiKey; const response = await fetch('/api/check-credits', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ hasApiKey }), }); const data = await response.json(); if (!response.ok) { toast({ title: "Error", description: "Failed to check credits", variant: "destructive", }); return; } if (hasApiKey || data.creditsRemaining === "unlimited") { // Has API key, unlimited setShowGenerateModal(true); } else if (data.creditsRemaining > 0) { // Has credits setShowGenerateModal(true); } else { // No credits left, show API modal setShowApiModal(true); toast({ title: "No credits remaining", description: "You get 1 credit weekly. Add an API key for unlimited generation.", variant: "destructive", }); } } catch (error) { console.error("Error checking credits:", error); toast({ title: "Error", description: "Failed to check credits", variant: "destructive", }); } }; const handleRedrawPage = () => { if (!isLoaded || !isSignedIn) { return; } if (!apiKey) { setShowApiModal(true); return; } setShowRedrawDialog(true); }; const confirmRedrawPage = async () => { setShowRedrawDialog(false); const currentPageData = pages[currentPage]; if (!currentPageData) return; setLoadingPageId(currentPage); try { const response = await fetch("/api/add-page", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey!, }, body: JSON.stringify({ storyId: story?.slug, pageId: currentPageData.dbId, // Add pageId to override existing page prompt: currentPageData.prompt, characterImages: currentPageData.characterUploads || [], }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to redraw page"); } const result = await response.json(); // Update the current page with the new image setPages((prevPages) => prevPages.map((page, index) => index === currentPage ? { ...page, image: result.imageUrl } : page ) ); toast({ title: "Page redrawn successfully", description: "The page has been regenerated with a fresh image.", duration: 3000, }); } catch (error) { console.error("Error redrawing page:", error); toast({ title: "Failed to redraw page", description: error instanceof Error ? error.message : "Failed to redraw page", variant: "destructive", duration: 4000, }); } finally { setLoadingPageId(null); } }; const handleApiKeyClick = () => { setShowApiModal(true); }; const downloadPDF = async () => { if (!story || pages.length === 0) return; setIsGeneratingPDF(true); try { const response = await fetch(`/api/download-pdf?storySlug=${story.slug}`); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to generate PDF"); } // Create blob from response and trigger download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${story.title}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); toast({ title: "PDF downloaded", description: "Your comic has been downloaded as a PDF.", duration: 3000, }); } catch (error) { console.error('Error generating PDF:', error); toast({ title: "Failed to generate PDF", description: "An error occurred while generating the PDF.", variant: "destructive", duration: 4000, }); } finally { setIsGeneratingPDF(false); } }; 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); }; const handleGeneratePage = async (data: { prompt: string; characterUrls?: string[]; }): 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(); // Update character images list with new ones const newCharacterUrls = data.characterUrls || []; setExistingCharacterImages((prev) => { const combined = [...prev, ...newCharacterUrls]; // Remove duplicates while preserving order const unique = Array.from(new Set(combined)); return unique; }); 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) { return (
); } if (!story) { return (
Story not found
); } return (
setShowInfoSheet(true)} onRedrawClick={handleRedrawPage} onDeletePage={() => handleDeletePage(currentPage)} onNextPage={() => setCurrentPage((prev) => prev < pages.length - 1 ? prev + 1 : prev ) } onPrevPage={() => setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)) } />
setShowApiModal(false)} onSubmit={handleApiKeySubmit} /> setShowGenerateModal(false)} onGenerate={handleGeneratePage} pageNumber={pages.length + 1} existingCharacters={existingCharacterImages} lastPageCharacters={ pages.length > 0 && pages[pages.length - 1]?.characterUploads ? pages[pages.length - 1].characterUploads || [] : [] } previousPageCharacters={ pages.length > 1 && pages[pages.length - 2]?.characterUploads ? pages[pages.length - 2].characterUploads || [] : [] } /> 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 Redraw Page Are you sure you want to redraw page {currentPage + 1}? This will regenerate the image for this page with a fresh result. Cancel Redraw
); }