"use client"; import { useState, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Upload, X, Check, ArrowRight, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; import { useS3Upload } from "next-s3-upload"; import { useAuth, SignInButton, useClerk } from "@clerk/nextjs"; import { COMIC_STYLES } from "@/lib/constants"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useApiKey } from "@/hooks/use-api-key"; import { isContentPolicyViolation } from "@/lib/utils"; import { ApiKeyModal } from "@/components/api-key-modal"; import { MAX_SYSTEM_LENGTH, MAX_USER_PROMPT } from "@/lib/prompt"; interface ComicCreationFormProps { prompt: string; setPrompt: (prompt: string) => void; style: string; setStyle: (style: string) => void; characterFiles: File[]; setCharacterFiles: (files: File[]) => void; isLoading: boolean; setIsLoading: (loading: boolean) => void; } const DEFAULT_STYLE = 'noir'; const STYLE_STORAGE_KEY = 'comic-style-preference'; export function ComicCreationForm({ prompt, setPrompt, style: initialStyle, setStyle: setParentStyle, characterFiles, setCharacterFiles, isLoading, setIsLoading, }: ComicCreationFormProps) { const router = useRouter(); const [loadingStep, setLoadingStep] = useState(0); const { toast } = useToast(); const { uploadToS3 } = useS3Upload(); const { isSignedIn, isLoaded } = useAuth(); const { openSignIn } = useClerk(); const [apiKey, setApiKey] = useApiKey(); const hasApiKey = !!apiKey; const [previews, setPreviews] = useState([]); const [showPreview, setShowPreview] = useState(null); const [showStyleDropdown, setShowStyleDropdown] = useState(false); const [creditsRemaining, setCreditsRemaining] = useState(null); const [showApiModal, setShowApiModal] = useState(false); // Initialize style with initial value, load from localStorage after mount const [style, setStyle] = useState(initialStyle || DEFAULT_STYLE); const fileInputRef = useRef(null); const textareaRef = useRef(null); const PROMPT_STORAGE_KEY = 'comic-prompt-draft'; useEffect(() => { if (isLoading) { setShowStyleDropdown(false); } }, [isLoading]); useEffect(() => { // Auto-focus the textarea when component mounts if (textareaRef.current) { textareaRef.current.focus(); } }, []); // Persist prompt to localStorage useEffect(() => { if (prompt) { localStorage.setItem(PROMPT_STORAGE_KEY, prompt); } }, [prompt]); // Restore prompt from localStorage only once on mount useEffect(() => { const saved = localStorage.getItem(PROMPT_STORAGE_KEY); if (saved && !prompt) { setPrompt(saved); } }, []); // Run only on mount // Load style preference from localStorage on mount useEffect(() => { const saved = localStorage.getItem(STYLE_STORAGE_KEY); if (saved) { setStyle(saved); } }, []); // Save style to localStorage and sync with parent useEffect(() => { localStorage.setItem(STYLE_STORAGE_KEY, style); setParentStyle(style); }, [style, setParentStyle]); // Fetch credits on mount useEffect(() => { if (isSignedIn && !hasApiKey) { const fetchCredits = async () => { try { const response = await fetch('/api/check-credits', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ hasApiKey: false }), }); const data = await response.json(); if (response.ok) { setCreditsRemaining(data.creditsRemaining); } } catch (error) { console.error('Error fetching credits:', error); } }; fetchCredits(); } else if (hasApiKey) { setCreditsRemaining(null); // Unlimited } }, [isSignedIn, hasApiKey]); // Keyboard shortcut for form submission useKeyboardShortcut(() => { if (!isLoading && prompt.trim()) { if (!isSignedIn) { openSignIn(); } else { handleCreate(); } } }, { disabled: isLoading || !isLoaded }); const handleFiles = (newFiles: FileList | null) => { if (!newFiles) return; const validFiles = Array.from(newFiles).filter((file) => file.type.startsWith("image/") ); const totalFiles = [...characterFiles, ...validFiles].slice(0, 2); // Max 2 files setCharacterFiles(totalFiles); // Generate previews for all files const newPreviews: string[] = []; totalFiles.forEach((file, index) => { const reader = new FileReader(); reader.onload = (e) => { newPreviews[index] = e.target?.result as string; if (newPreviews.filter(Boolean).length === totalFiles.length) { setPreviews([...newPreviews]); } }; reader.readAsDataURL(file); }); }; const removeFile = (index: number) => { const newFiles = characterFiles.filter((_, i) => i !== index); const newPreviews = previews.filter((_, i) => i !== index); setCharacterFiles(newFiles); setPreviews(newPreviews); setShowPreview(null); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if (!target.closest(".dropdown-container")) { setShowStyleDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleCreate = async () => { if (!prompt.trim()) { toast({ title: "Prompt required", description: "Please enter a prompt to generate your comic", variant: "destructive", duration: 3000, }); return; } setIsLoading(true); setLoadingStep(0); // Progress through loading steps const stepInterval = setInterval(() => { setLoadingStep((prev) => { if (prev < 3) return prev + 1; return prev; }); }, 3500); try { // Check credits const hasApiKey = !!apiKey; if (!hasApiKey) { const creditsResponse = await fetch('/api/check-credits', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ hasApiKey }), }); const creditsData = await creditsResponse.json(); if (!creditsResponse.ok) { toast({ title: "Error", description: "Failed to check credits", variant: "destructive", }); clearInterval(stepInterval); setIsLoading(false); return; } if (creditsData.creditsRemaining === 0) { setShowApiModal(true); clearInterval(stepInterval); setIsLoading(false); return; } } const characterUploads = await Promise.all( characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)) ); // Use API to create story and generate first page const response = await fetch("/api/generate-comic", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt, ...(apiKey && { apiKey }), style, characterImages: characterUploads, }), }); if (!response.ok) { const errorData = await response.json(); if (response.status === 429 && errorData.isRateLimited) { throw new Error(errorData.error); } throw new Error(errorData.error || "Failed to create story"); } const result = await response.json(); // Clear the draft since submission was successful localStorage.removeItem(PROMPT_STORAGE_KEY); clearInterval(stepInterval); // Redirect to the story editor using slug router.push(`/story/${result.storySlug}`); } catch (error) { console.error("Error creating comic:", error); const errorMessage = error instanceof Error ? error.message : "Failed to create comic. Please try again."; let title = "Creation failed"; if (isContentPolicyViolation(errorMessage)) { title = "Content policy violation"; } toast({ title, description: errorMessage, variant: "destructive", duration: 4000, }); clearInterval(stepInterval); setIsLoading(false); } }; const handleApiKeySubmit = (key: string) => { setApiKey(key); setShowApiModal(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { const isEnter = e.key === "Enter" || e.key === "\n" || e.keyCode === 13; const isModifierPressed = e.shiftKey || e.ctrlKey || e.metaKey; // metaKey for Cmd on Mac if (isEnter && isModifierPressed) { e.preventDefault(); handleCreate(); } }; const loadingSteps = [ "Enhancing prompt...", "Generating scenes...", "Creating your comic...", "Finishing up...", ]; return ( <>