diff --git a/app/editor/[storySlug]/page.tsx b/app/editor/[storySlug]/page.tsx index 15b328a..461d913 100644 --- a/app/editor/[storySlug]/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -1,191 +1,155 @@ -"use client" +"use client"; -import { useState, useEffect } from "react" -import { useParams } from "next/navigation" -import { useToast } from "@/hooks/use-toast" -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 { useS3Upload } from "next-s3-upload" +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useToast } from "@/hooks/use-toast"; +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"; interface PageData { - id: number // pageNumber for component compatibility - title: string - image: string - prompt: string - characterUploads?: string[] - style: string - dbId?: string // actual database UUID + 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 - title: string - description?: string | null - userId?: string | null + id: string; + title: string; + description?: string | null; + userId?: string | null; } export default function StoryEditorPage() { - const params = useParams() - const slug = params.storySlug as string + const params = useParams(); + const slug = params.storySlug as string; - const [story, setStory] = useState(null) - 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 [loadingPageId, setLoadingPageId] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [existingCharacterImages, setExistingCharacterImages] = useState([]) - const { uploadToS3 } = useS3Upload() - const { toast } = useToast() + const [story, setStory] = useState(null); + 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 [loadingPageId, setLoadingPageId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [existingCharacterImages, setExistingCharacterImages] = useState< + string[] + >([]); + const { toast } = useToast(); // Load story and pages from API useEffect(() => { const loadStoryData = async () => { try { - const response = await fetch(`/api/stories/${slug}`) + const response = await fetch(`/api/stories/${slug}`); if (!response.ok) { - throw new Error("Story not found") + throw new Error("Story not found"); } - const result = await response.json() - const { story: storyData, pages: pagesData } = result + const result = await response.json(); + const { story: storyData, pages: pagesData } = result; - setStory(storyData) - 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, - }))) + setStory(storyData); + 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[]) - + const uniqueImages = [ + ...new Set( + pagesData.flatMap((page: any) => page.characterImageUrls || []) + ), + ]; + setExistingCharacterImages(uniqueImages as string[]); } catch (error) { - console.error("Error loading story:", 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) + setIsLoading(false); } - } + }; if (slug) { - loadStoryData() + loadStoryData(); } - }, [slug, toast]) + }, [slug, toast]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight") { - setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)) + setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)); } else if (e.key === "ArrowLeft") { - setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)) + setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)); } - } + }; - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [pages.length]) + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [pages.length]); const handleAddPage = () => { - const storedKey = localStorage.getItem("together_api_key") + const storedKey = localStorage.getItem("together_api_key"); if (!storedKey && pages.length >= 1) { - setShowApiModal(true) - return + setShowApiModal(true); + return; } - setShowGenerateModal(true) - } - - const handleContinueStory = () => { - const storedKey = localStorage.getItem("together_api_key") - if (!storedKey) { - setShowApiModal(true) - return - } - setShowGenerateModal(true) - } + setShowGenerateModal(true); + }; const handleApiKeyClick = () => { - setShowApiModal(true) - } + setShowApiModal(true); + }; const handleApiKeySubmit = (key: string) => { - localStorage.setItem("together_api_key", key) - setShowApiModal(false) - const wasGenerating = showGenerateModal + localStorage.setItem("together_api_key", key); + setShowApiModal(false); + const wasGenerating = showGenerateModal; if (wasGenerating) { - setShowGenerateModal(true) + setShowGenerateModal(true); } toast({ title: "API key saved", description: "Your Together API key has been saved successfully", duration: 3000, - }) - } + }); + }; const handleGeneratePage = async (data: { - prompt: string - style: string - characterFiles?: File[] - characterUrls?: string[] // For reusing existing characters - isContinuation?: boolean + prompt: string; + style: string; + characterFiles?: File[]; + characterUrls?: string[]; + isContinuation?: boolean; }) => { - if (!story) return - - setShowGenerateModal(false) - try { - // Handle new character uploads - let characterUploads: string[] = data.characterUrls || [] - - if (data.characterFiles && data.characterFiles.length > 0) { - const newUploads = await Promise.all( - data.characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)) - ) - characterUploads = [...characterUploads, ...newUploads] - } - - // Add loading page to UI - const nextPageNumber = pages.length + 1 - const pageData: PageData = { - id: nextPageNumber, - title: story.title, - image: "", - prompt: data.prompt, - characterUploads, - style: data.style, - } - - setPages([...pages, pageData]) - setCurrentPage(pages.length) - setLoadingPageId(nextPageNumber) - - // Generate the comic image - const apiKey = localStorage.getItem("together_api_key") + const apiKey = localStorage.getItem("together_api_key"); if (!apiKey) { - throw new Error("API key not found") + setShowApiModal(true); + return; } - const previousPage = pages[pages.length - 1] - const response = await fetch("/api/generate-comic", { method: "POST", headers: { @@ -196,66 +160,48 @@ export default function StoryEditorPage() { prompt: data.prompt, apiKey, style: data.style, - characterImages: characterUploads, - isContinuation: data.isContinuation, - previousContext: data.isContinuation ? previousPage?.prompt : undefined, + characterImages: data.characterUrls || [], }), - }) + }); if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || "Failed to generate image") + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to generate image"); } - const result = await response.json() + const result = await response.json(); - // Update page with generated image - setPages((prevPages) => - prevPages.map((page) => - page.id === nextPageNumber - ? { - ...page, - image: result.imageUrl, - dbId: result.pageId, - } - : page, - ), - ) - - // Update existing character images for future reuse - if (characterUploads.length > 0) { - const updatedImages = [...new Set([...existingCharacterImages, ...characterUploads])] - setExistingCharacterImages(updatedImages) - } - - toast({ - title: "Page generated successfully", - description: `Page ${nextPageNumber} is ready`, - duration: 4000, - }) + setPages((prevPages) => [ + ...prevPages, + { + id: pages.length + 1, + title: story?.title || "", + image: result.imageUrl, + prompt: data.prompt, + characterUploads: data.characterUrls || [], + style: data.style, + }, + ]); + setCurrentPage(pages.length); + setShowGenerateModal(false); } catch (error) { - console.error("Error generating page:", error) + console.error("Error generating page:", error); toast({ title: "Generation failed", - description: error instanceof Error ? error.message : "Failed to generate comic page. Please try again.", + description: + error instanceof Error ? error.message : "Failed to generate page", variant: "destructive", duration: 4000, - }) - - // Remove failed page from state - setPages((prevPages) => prevPages.filter((page) => page.id !== loadingPageId)) - setCurrentPage(Math.max(0, pages.length - 1)) - } finally { - setLoadingPageId(null) + }); } - } + }; if (isLoading) { return (
Loading story...
- ) + ); } if (!story) { @@ -263,14 +209,13 @@ export default function StoryEditorPage() {
Story not found
- ) + ); } return (
setShowInfoSheet(true)} /> @@ -286,18 +231,29 @@ export default function StoryEditorPage() {
- setShowApiModal(false)} onSubmit={handleApiKeySubmit} /> + setShowApiModal(false)} + onSubmit={handleApiKeySubmit} + /> setShowGenerateModal(false)} onGenerate={handleGeneratePage} pageNumber={pages.length + 1} - previousCharacters={[]} // Will be updated with character reuse - previousPagePrompt={pages[pages.length - 1]?.prompt} - previousPageStyle={pages[pages.length - 1]?.style?.toLowerCase()} - existingCharacterImages={existingCharacterImages} + allPages={pages.map((page, index) => ({ + pageNumber: page.id, + characterImages: page.characterUploads || [], + prompt: page.prompt, + imageUrl: page.image, + style: page.style, + }))} + /> + setShowInfoSheet(false)} + page={pages[currentPage]} /> - setShowInfoSheet(false)} page={pages[currentPage]} /> - ) -} \ No newline at end of file + ); +} diff --git a/components/editor/editor-toolbar.tsx b/components/editor/editor-toolbar.tsx index 832c64d..b9ad214 100644 --- a/components/editor/editor-toolbar.tsx +++ b/components/editor/editor-toolbar.tsx @@ -6,11 +6,10 @@ import { useRouter } from "next/navigation" interface EditorToolbarProps { title: string - onContinueStory: () => void onInfoClick: () => void } -export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToolbarProps) { +export function EditorToolbar({ title, onInfoClick }: EditorToolbarProps) { const router = useRouter() return ( @@ -53,15 +52,6 @@ export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToo Download PDF - - ) diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx index 8451b54..453c03a 100644 --- a/components/editor/generate-page-modal.tsx +++ b/components/editor/generate-page-modal.tsx @@ -8,6 +8,14 @@ import { useToast } from "@/hooks/use-toast" import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils" import { COMIC_STYLES } from "@/lib/constants" +interface PageReference { + pageNumber: number + characterImages: string[] + prompt: string + imageUrl?: string + style: string +} + interface GeneratePageModalProps { isOpen: boolean onClose: () => void @@ -15,14 +23,11 @@ interface GeneratePageModalProps { prompt: string style: string characterFiles?: File[] - characterUrls?: string[] // For reusing existing characters + characterUrls?: string[] isContinuation?: boolean }) => void pageNumber: number - previousCharacters?: File[] - previousPagePrompt?: string - previousPageStyle?: string - existingCharacterImages?: string[] // Character images from previous pages + allPages: PageReference[] } export function GeneratePageModal({ @@ -30,55 +35,43 @@ export function GeneratePageModal({ onClose, onGenerate, pageNumber, - previousCharacters, - previousPagePrompt, - previousPageStyle, - existingCharacterImages = [], + allPages, }: GeneratePageModalProps) { const [prompt, setPrompt] = useState("") - const [uploadedFiles, setUploadedFiles] = useState(previousCharacters || []) + const [uploadedFiles, setUploadedFiles] = useState([]) const [selectedExistingCharacters, setSelectedExistingCharacters] = useState([]) + const [referencePageNumber, setReferencePageNumber] = useState(allPages.length > 0 ? allPages.length : 1) + const [pageImage, setPageImage] = useState(null) const [previews, setPreviews] = useState([]) const [showPreview, setShowPreview] = useState(null) const [isGenerating, setIsGenerating] = useState(false) - const [isContinuing, setIsContinuing] = useState(false) const fileInputRef = useRef(null) const { toast } = useToast() - const selectedStyleId = previousPageStyle || "noir" - const selectedStyle = useMemo( - () => COMIC_STYLES.find((s) => s.id === selectedStyleId)?.name || "Noir", - [selectedStyleId] - ) + const referencePage = allPages.find((p) => p.pageNumber === referencePageNumber) || null + const referenceStyleId = referencePage?.style || "noir" + const selectedStyleName = COMIC_STYLES.find((s) => s.id === referenceStyleId)?.name || "Noir" + const selectedStyle = useMemo(() => selectedStyleName, [referenceStyleId]) useEffect(() => { - if (previousCharacters && previousCharacters.length > 0) { - const newPreviews: string[] = [] - previousCharacters.forEach((file, index) => { - const reader = new FileReader() - reader.onload = (e) => { - newPreviews[index] = e.target?.result as string - if (newPreviews.filter(Boolean).length === previousCharacters.length) { - setPreviews([...newPreviews]) - } - } - reader.readAsDataURL(file) - }) + if (isOpen && referencePage) { + setSelectedExistingCharacters(referencePage.characterImages) + setPageImage(referencePage.imageUrl || null) + } else if (isOpen && !referencePage) { + setPageImage(null) } - }, [previousCharacters]) + }, [isOpen, referencePage]) const handleFiles = async (newFiles: FileList | null) => { if (!newFiles) return const filesArray = Array.from(newFiles) - // Validate files (including WebP rejection) const validationResults = filesArray.map(file => ({ file, validation: validateFileForUpload(file, true) })) - // Show errors for invalid files validationResults.forEach(({ validation }) => { if (!validation.valid && validation.error) { toast({ @@ -96,10 +89,9 @@ export function GeneratePageModal({ if (validFiles.length === 0) return - const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files + const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) setUploadedFiles(totalFiles) - // Generate previews for all files const newPreviews = await Promise.all( totalFiles.map((file) => generateFilePreview(file)) ) @@ -125,34 +117,54 @@ export function GeneratePageModal({ ) } - const handleGenerate = () => { + const togglePageImage = () => { + setPageImage(prev => prev === null && referencePage?.imageUrl ? referencePage.imageUrl : null) + } + + const handleGenerate = async () => { if (!prompt.trim()) return setIsGenerating(true) + + const fileDataUrls = await Promise.all( + uploadedFiles.map((file, index) => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = (e) => { + const base64 = e.target?.result as string + const [header, base64Data] = base64.split(",") + const base64String = base64Data.replace(/_/g, "/") + resolve(`data:image/jpeg;base64,${base64String}`) + } + reader.readAsDataURL(file) + }) + }) + ) + + const allCharacterUrls: string[] = [ + ...selectedExistingCharacters, + ...fileDataUrls, + ...(pageImage ? [pageImage] : []), + ] + onGenerate({ prompt, style: selectedStyle, characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined, + characterUrls: allCharacterUrls, isContinuation: false, }) } - const handleContinue = () => { - setIsContinuing(true) - onGenerate({ - prompt: prompt.trim() || `Continue the story from where it left off. Previous context: ${previousPagePrompt}`, - style: selectedStyle, - characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined, - isContinuation: true, - }) - } - useEffect(() => { if (!isOpen) { setIsGenerating(false) - setIsContinuing(false) setPrompt("") + setUploadedFiles([]) + setSelectedExistingCharacters([]) + setPageImage(null) + if (allPages.length > 0) { + setReferencePageNumber(allPages.length) + } } }, [isOpen]) @@ -165,6 +177,26 @@ export function GeneratePageModal({
+ {/* Reference Page Selection */} + {allPages.length > 0 && ( +
+ + +
+ )} +
@@ -172,26 +204,55 @@ export function GeneratePageModal({ Prompt
- {selectedStyle} + {selectedStyleName}