"use client"; import { useState, useRef, useEffect } from "react"; import { Upload, X, Loader2, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, } from "@/components/ui/dialog"; 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"; import { isContentPolicyViolation } from "@/lib/utils"; import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt"; interface CharacterItem { url: string; isNew?: boolean; file?: File; preview?: string; } interface GeneratePageModalProps { isOpen: boolean; onClose: () => void; onGenerate: (data: { prompt: string; characterUrls?: string[]; }) => Promise; pageNumber: number; isRedrawMode?: boolean; existingPrompt?: string; existingCharacters?: string[]; // All characters from the story lastPageCharacters?: string[]; // Characters used on the last page previousPageCharacters?: string[]; // Characters used on the previous page (if last page had < 2) } export function GeneratePageModal({ isOpen, onClose, onGenerate, pageNumber, isRedrawMode = false, existingPrompt = "", existingCharacters = [], lastPageCharacters = [], previousPageCharacters = [], }: GeneratePageModalProps) { const [prompt, setPrompt] = useState(""); const [characters, setCharacters] = useState([]); const [selectedCharacterIndices, setSelectedCharacterIndices] = useState< Set >(new Set()); const [showPreview, setShowPreview] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const fileInputRef = useRef(null); const { toast } = useToast(); const { uploadToS3 } = useS3Upload(); // Reset form and initialize characters when modal opens useEffect(() => { if (isOpen) { setPrompt(isRedrawMode ? existingPrompt : ""); setShowPreview(null); setIsGenerating(false); // Initialize characters list with existing ones const existingItems: CharacterItem[] = existingCharacters.map((url) => ({ url, isNew: false, })); setCharacters(existingItems); // Smart selection: Use last 2 characters from last page, or combine with previous page if needed const defaultSelected = new Set(); const charactersToSelect: string[] = []; // If last page has 2 characters, use those if (lastPageCharacters.length >= 2) { charactersToSelect.push(...lastPageCharacters.slice(0, 2)); } else { // Start with last page characters (if any) charactersToSelect.push(...lastPageCharacters); // If we have less than 2, add from previous page (avoiding duplicates) if ( charactersToSelect.length < 2 && previousPageCharacters.length > 0 ) { for (const charUrl of previousPageCharacters) { if ( !charactersToSelect.includes(charUrl) && charactersToSelect.length < 2 ) { charactersToSelect.push(charUrl); } } } } // Find indices of characters to select (preserving order in existingItems) charactersToSelect.forEach((charUrl) => { const index = existingItems.findIndex((item) => item.url === charUrl); if (index !== -1) { defaultSelected.add(index); } }); setSelectedCharacterIndices(defaultSelected); } }, [ isOpen, isRedrawMode, existingPrompt, existingCharacters, lastPageCharacters, previousPageCharacters, ]); // Keyboard shortcut for form submission (disabled during generation) useKeyboardShortcut( () => { if (isOpen && !isGenerating && prompt.trim()) { handleGenerate(); } }, { disabled: !isOpen || isGenerating } ); const handleFiles = async (newFiles: FileList | null) => { if (!newFiles) return; const filesArray = Array.from(newFiles); const validationResults = filesArray.map((file) => ({ file, validation: validateFileForUpload(file, true), })); validationResults.forEach(({ validation }) => { if (!validation.valid && validation.error) { toast({ title: "Invalid file", description: validation.error, variant: "destructive", duration: 4000, }); } }); const validFiles = validationResults .filter(({ validation }) => validation.valid) .map(({ file }) => file); if (validFiles.length === 0) return; // Create new character items for the uploaded files const newCharacterItems: CharacterItem[] = await Promise.all( validFiles.map(async (file) => { const preview = await generateFilePreview(file); return { url: "", // Will be set after S3 upload isNew: true, file, preview, }; }) ); // Add new characters to the list setCharacters((prev) => { const updated = [...prev, ...newCharacterItems]; const newSelected = new Set(selectedCharacterIndices); // Add new characters to selection newCharacterItems.forEach((_, idx) => { newSelected.add(prev.length + idx); }); // If we have more than 2 selected, deselect the oldest ones (keep most recent 2) if (newSelected.size > 2) { const selectedArray = Array.from(newSelected).sort((a, b) => b - a); const toKeep = selectedArray.slice(0, 2); newSelected.clear(); toKeep.forEach((idx) => newSelected.add(idx)); } setSelectedCharacterIndices(newSelected); return updated; }); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const toggleCharacterSelection = (index: number) => { setSelectedCharacterIndices((prev) => { const newSelected = new Set(prev); if (newSelected.has(index)) { // Allow deselection even if only 2 are selected newSelected.delete(index); } else { // If already at max (2), remove the oldest selected first if (newSelected.size >= 2) { const selectedArray = Array.from(newSelected).sort((a, b) => a - b); newSelected.delete(selectedArray[0]); // Remove oldest } newSelected.add(index); } return newSelected; }); }; const removeCharacter = (index: number) => { setCharacters((prev) => { const updated = prev.filter((_, i) => i !== index); // Adjust selected indices setSelectedCharacterIndices((prevSelected) => { const newSelected = new Set(); prevSelected.forEach((idx) => { if (idx < index) { newSelected.add(idx); } else if (idx > index) { newSelected.add(idx - 1); } // Skip the removed index }); return newSelected; }); return updated; }); setShowPreview(null); }; const handleGenerate = async () => { if (!prompt.trim()) return; setIsGenerating(true); try { // Get selected characters const selectedCharacters = Array.from(selectedCharacterIndices) .sort((a, b) => a - b) .map((idx) => characters[idx]) .filter(Boolean); // Upload new files to S3 and get URLs, reuse existing URLs const characterUrls = await Promise.all( selectedCharacters.map(async (char) => { if (char.isNew && char.file) { // Upload new file to S3 const { url } = await uploadToS3(char.file); return url; } else { // Reuse existing URL return char.url; } }) ); await onGenerate({ prompt, characterUrls: characterUrls.length > 0 ? characterUrls : undefined, }); } catch (error) { console.error("Error generating page:", error); const errorMessage = error instanceof Error ? error.message : "Failed to generate page. Please try again."; let title = "Generation failed"; if (isContentPolicyViolation(errorMessage)) { title = "Content policy violation"; } toast({ title, description: errorMessage, variant: "destructive", duration: 4000, }); setIsGenerating(false); throw error; // Re-throw so the parent handler knows generation failed } }; const handleOpenChange = (open: boolean) => { // Prevent closing the modal if generation is running if (!open && isGenerating) { return; } onClose(); }; return ( <> {isRedrawMode ? `Redraw Page ${pageNumber}` : `Generate Page ${pageNumber}`} Close
{/* Prompt Input */}