diff --git a/app/api/add-page/route.ts b/app/api/add-page/route.ts new file mode 100644 index 0000000..08508ed --- /dev/null +++ b/app/api/add-page/route.ts @@ -0,0 +1,228 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import Together from "together-ai"; +import { db } from "@/lib/db"; +import { pages } from "@/lib/schema"; +import { eq } from "drizzle-orm"; +import { + updatePage, + createPage, + getNextPageNumber, + getStoryWithPagesBySlug, + getLastPageImage, +} from "@/lib/db-actions"; +import { freeTierRateLimit } from "@/lib/rate-limit"; +import { uploadImageToS3 } from "@/lib/s3-upload"; +import { buildComicPrompt } from "@/lib/prompt"; + +const NEW_MODEL = false; + +const IMAGE_MODEL = NEW_MODEL + ? "google/gemini-3-pro-image" + : "google/flash-image-2.5"; + +const FIXED_DIMENSIONS = NEW_MODEL + ? { width: 896, height: 1200 } + : { width: 864, height: 1184 }; + +export async function POST(request: NextRequest) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 } + ); + } + + const { storyId, pageId, prompt, characterImages = [] } = await request.json(); + + if (!storyId || !prompt) { + return NextResponse.json( + { error: "Missing required fields: storyId and prompt" }, + { status: 400 } + ); + } + + // Get the story and all its pages + const storyData = await getStoryWithPagesBySlug(storyId); + 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 }); + } + + // Apply rate limiting for free tier + const hasApiKey = request.headers.get('x-api-key'); + if (!hasApiKey) { + const { success, reset } = await freeTierRateLimit.limit(userId); + if (!success) { + const resetDate = new Date(reset); + const timeUntilReset = Math.ceil( + (reset - Date.now()) / (1000 * 60 * 60 * 24) + ); + return NextResponse.json( + { + error: `Free tier limit reached. You can generate 1 comic per week. Try again in ${timeUntilReset} day(s), or provide your own API key.`, + resetDate: resetDate.toISOString(), + isRateLimited: true, + }, + { status: 429 } + ); + } + } + + let page; + let pageNumber; + let isRedraw = false; + + if (pageId) { + // Redraw mode: update existing page + isRedraw = true; + const storyData = await getStoryWithPagesBySlug(storyId); + if (!storyData) { + return NextResponse.json({ error: "Story not found" }, { status: 404 }); + } + + const existingPage = storyData.pages.find(p => p.id === pageId); + if (!existingPage) { + return NextResponse.json({ error: "Page not found" }, { status: 404 }); + } + + page = existingPage; + pageNumber = existingPage.pageNumber; + } else { + // Add new page mode + pageNumber = await getNextPageNumber(story.id); + page = await createPage({ + storyId: story.id, + pageNumber, + prompt, + characterImageUrls: characterImages, + }); + } + + const dimensions = FIXED_DIMENSIONS; + + // Collect reference images: previous page + story characters + current characters + let referenceImages: string[] = []; + + // Get previous page image for style consistency (unless it's page 1) + if (pageNumber > 1) { + if (isRedraw) { + // For redraw, get all pages and find the previous page's image + const storyData = await getStoryWithPagesBySlug(storyId); + if (storyData) { + const previousPage = storyData.pages.find(p => p.pageNumber === pageNumber - 1); + if (previousPage?.generatedImageUrl) { + referenceImages.push(previousPage.generatedImageUrl); + } + } + } else { + // For new page, use the last page image + const lastPageImage = await getLastPageImage(story.id); + if (lastPageImage) { + referenceImages.push(lastPageImage); + } + } + } + + // Use only the character images sent from the frontend (user's selection) + // These are already the most recent/relevant characters the user wants to use + referenceImages.push(...characterImages); + + // Build the prompt with continuation context + const previousPages = pages.map(p => ({ + prompt: p.prompt, + characterImages: p.characterImageUrls, + })); + + const fullPrompt = buildComicPrompt({ + prompt, + style: story.style, + characterImages, + isAddPage: true, + previousPages, + }); + + const client = new Together({ apiKey: process.env.TOGETHER_API_KEY_DEFAULT }); + + let response; + try { + response = await client.images.generate({ + model: IMAGE_MODEL, + prompt: fullPrompt, + width: dimensions.width, + height: dimensions.height, + temperature: 0.1, + reference_images: referenceImages.length > 0 ? referenceImages : undefined, + }); + } catch (error) { + console.error("Together AI API error:", error); + + if (error instanceof Error && "status" in error) { + const status = (error as any).status; + if (status === 402) { + return NextResponse.json( + { + error: "Insufficient API credits.", + errorType: "credit_limit", + }, + { status: 402 } + ); + } + return NextResponse.json( + { + error: error.message || `Failed to generate image: ${status}`, + errorType: "api_error", + }, + { status: status || 500 } + ); + } + + return NextResponse.json( + { + error: `Internal server error: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + { status: 500 } + ); + } + + if (!response.data || !response.data[0] || !response.data[0].url) { + return NextResponse.json( + { error: "No image URL in response" }, + { status: 500 } + ); + } + + const imageUrl = response.data[0].url; + const s3Key = `${story.id}/page-${page.pageNumber}-${Date.now()}.jpg`; + const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key); + + await updatePage(page.id, s3ImageUrl); + + return NextResponse.json({ + imageUrl: s3ImageUrl, + pageId: page.id, + pageNumber: page.pageNumber, + }); + } catch (error) { + console.error("Error in add-page API:", error); + return NextResponse.json( + { + error: `Internal server error: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/delete-page/route.ts b/app/api/delete-page/route.ts new file mode 100644 index 0000000..9deaca0 --- /dev/null +++ b/app/api/delete-page/route.ts @@ -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 } + ); + } +} \ No newline at end of file diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index f775c5a..522fe64 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -3,14 +3,18 @@ import Together from "together-ai"; import { auth } from "@clerk/nextjs/server"; import { updatePage, + updateStory, createStory, createPage, getNextPageNumber, getStoryById, + getLastPageImage, + getStoryCharacterImages, } from "@/lib/db-actions"; import { freeTierRateLimit } from "@/lib/rate-limit"; import { COMIC_STYLES } from "@/lib/constants"; import { uploadImageToS3 } from "@/lib/s3-upload"; +import { buildComicPrompt } from "@/lib/prompt"; const NEW_MODEL = false; @@ -22,6 +26,8 @@ const FIXED_DIMENSIONS = NEW_MODEL ? { width: 896, height: 1200 } : { width: 864, height: 1184 }; +const TEXT_MODEL = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"; + export async function POST(request: NextRequest) { try { const { userId } = await auth(); @@ -88,9 +94,11 @@ export async function POST(request: NextRequest) { let page; let story; + let referenceImages: string[] = []; if (storyId) { - const story = await getStoryById(storyId); + // Continuation: get previous page image and story character images + story = await getStoryById(storyId); if (!story) { return NextResponse.json({ error: "Story not found" }, { status: 404 }); } @@ -102,7 +110,20 @@ export async function POST(request: NextRequest) { prompt, characterImageUrls: characterImages, }); + + // Get previous page image for style consistency (unless it's page 1) + if (nextPageNumber > 1) { + const lastPageImage = await getLastPageImage(storyId); + if (lastPageImage) { + referenceImages.push(lastPageImage); + } + } + + // For continuation pages, character images are sent from frontend + // No need to fetch separately - frontend handles selection } else { + // New story: no previous page reference + // Create story with temporary title, will update with generated title story = await createStory({ title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt, description: undefined, @@ -118,78 +139,104 @@ export async function POST(request: NextRequest) { }); } + // Use only the character images sent from the frontend + referenceImages.push(...characterImages); + const dimensions = FIXED_DIMENSIONS; - const styleInfo = COMIC_STYLES.find((s) => s.id === style); - const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt; - const continuationContext = - isContinuation && previousContext - ? `\nCONTINUATION CONTEXT:\nThis is a continuation of an existing story. The previous page showed: ${previousContext}\nMaintain visual consistency with the previous panels. Continue the narrative naturally.\n` - : ""; - - let characterSection = ""; - if (characterImages.length > 0) { - if (characterImages.length === 1) { - characterSection = ` -CRITICAL FACE CONSISTENCY INSTRUCTIONS: -- REFERENCE CHARACTER: Use the uploaded image as EXACT reference for the protagonist's face and appearance -- FACE MATCHING: The character's face must be IDENTICAL to the reference image - same eyes, nose, mouth, hair, facial structure -- APPEARANCE PRESERVATION: Maintain exact skin tone, hair color/style, eye color, and distinctive facial features -- CHARACTER CONSISTENCY: This exact same character must appear in ALL 5 panels with the same face throughout -- STYLE APPLICATION: Apply ${style} comic art style to the body/pose/action but KEEP THE FACE EXACTLY AS IN THE REFERENCE IMAGE -- NO VARIATION: Do not alter, modify, or change the character's face in any way from the reference`; - } else if (characterImages.length === 2) { - characterSection = ` -CRITICAL DUAL CHARACTER FACE CONSISTENCY INSTRUCTIONS: -- CHARACTER 1 REFERENCE: Use the FIRST uploaded image as EXACT reference for Character 1's face and appearance -- CHARACTER 2 REFERENCE: Use the SECOND uploaded image as EXACT reference for Character 2's face and appearance -- FACE MATCHING: Both characters' faces must be IDENTICAL to their respective reference images -- VISUAL DISTINCTION: Keep both characters clearly visually distinct with their unique faces, hair, and features -- CONSISTENT PRESENCE: Both characters must appear together in at least 4 of the 5 panels -- STYLE APPLICATION: Apply ${style} comic art style while maintaining EXACT facial features from references -- NO FACE VARIATION: Never alter or modify either character's face from their reference images`; - } - } - - const systemPrompt = `Professional comic book page illustration. -${continuationContext} -${characterSection} - -CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY): -- If reference images are provided, the characters' FACES must be 100% identical to the reference images -- Never change hair color, eye color, facial structure, or distinctive features -- Apply comic style to body/pose/action but preserve exact facial appearance -- Same character must look identical across all panels they appear in - -TEXT AND LETTERING (CRITICAL): -- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled -- Use bold clean comic book lettering, large and easy to read -- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker -- Keep dialogue SHORT: maximum 1-2 sentences per bubble -- NO blurry, warped, or unreadable text - -PAGE LAYOUT: -5-panel comic page arranged as: -[Panel 1] [Panel 2] — top row, 2 equal panels -[ Panel 3 ] — middle row, 1 large cinematic hero panel -[Panel 4] [Panel 5] — bottom row, 2 equal panels -- Solid black panel borders with clean white gutters between panels -- Each panel clearly separated and distinct - -ART STYLE: -${styleDesc} -${characterSection} - -COMPOSITION: -- Vary camera angles across panels: close-up, medium shot, wide establishing shot -- Natural visual flow: left-to-right, top-to-bottom reading order -- Dynamic character poses with clear expressive acting -- Detailed backgrounds matching the scene and mood`; - - const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`; + const fullPrompt = buildComicPrompt({ + prompt, + style, + characterImages, + isContinuation, + previousContext, + }); const client = new Together({ apiKey: finalApiKey }); + // Generate title and description in parallel with image generation (only for new stories) + let titleGenerationPromise: Promise<{ + title: string; + description: string; + }> | null = null; + if (!storyId) { + titleGenerationPromise = (async () => { + try { + const titlePrompt = `Based on this comic book prompt, generate a compelling title and description for the comic book. + +Prompt: "${prompt}" +Style: ${COMIC_STYLES.find((s) => s.id === style)?.name || style} + +Generate: +1. A catchy, engaging title (maximum 60 characters) +2. A brief description (2-3 sentences, maximum 200 characters) + +Format your response as JSON: +{ + "title": "Title here", + "description": "Description here" +} + +Only return the JSON, no other text.`; + + const textResponse = await client.chat.completions.create({ + model: TEXT_MODEL, + messages: [ + { + role: "system", + content: + "You are a creative assistant that generates compelling comic book titles and descriptions. Always respond with valid JSON only.", + }, + { + role: "user", + content: titlePrompt, + }, + ], + temperature: 0.8, + max_tokens: 300, + }); + + const content = textResponse.choices[0]?.message?.content?.trim(); + if (!content) { + throw new Error("No response from text generation"); + } + + // Extract JSON from response (in case there's extra text) + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("No JSON found in response"); + } + + const parsed = JSON.parse(jsonMatch[0]); + const rawTitle = + parsed.title?.trim() || + (prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt); + const rawDescription = parsed.description?.trim(); + + // Enforce character limits + const title = + rawTitle.length > 60 ? rawTitle.substring(0, 57) + "..." : rawTitle; + const description = + rawDescription && rawDescription.length > 200 + ? rawDescription.substring(0, 197) + "..." + : rawDescription; + + return { + title, + description: description || undefined, + }; + } catch (error) { + console.error("Error generating title and description:", error); + // Fallback to prompt-based title + return { + title: + prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt, + description: undefined, + }; + } + })(); + } + let response; try { response = await client.images.generate({ @@ -199,7 +246,7 @@ COMPOSITION: height: dimensions.height, temperature: 0.1, // Lower temperature for more consistent face matching reference_images: - characterImages.length > 0 ? characterImages : undefined, + referenceImages.length > 0 ? referenceImages : undefined, }); } catch (error) { console.error("Together AI API error:", error); @@ -245,9 +292,37 @@ COMPOSITION: const imageUrl = response.data[0].url; // Upload image to S3 for permanent storage - const s3Key = `${storyId || story!.id}/page-${page.pageNumber}-${Date.now()}.jpg`; + const s3Key = `${storyId || story!.id}/page-${ + page.pageNumber + }-${Date.now()}.jpg`; const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key); + // Wait for title/description generation if it's a new story + let generatedTitle: string | undefined; + let generatedDescription: string | undefined; + if (titleGenerationPromise) { + const titleData = await titleGenerationPromise; + generatedTitle = titleData.title; + generatedDescription = titleData.description; + + // Update story with generated title and description + try { + await updateStory(story!.id, { + title: generatedTitle, + description: generatedDescription, + }); + // Update story object for response + story = { + ...story, + title: generatedTitle, + description: generatedDescription, + }; + } catch (dbError) { + console.error("Error updating story title/description:", dbError); + // Continue even if update fails + } + } + // Update page in database with S3 URL try { await updatePage(page.id, s3ImageUrl); @@ -267,6 +342,8 @@ COMPOSITION: storySlug: story!.slug, pageId: page.id, pageNumber: page.pageNumber, + title: generatedTitle || story!.title, + description: generatedDescription || story!.description, }; return NextResponse.json(responseData); diff --git a/app/api/stories/[storySlug]/route.ts b/app/api/stories/[storySlug]/route.ts index fcc9984..63e633e 100644 --- a/app/api/stories/[storySlug]/route.ts +++ b/app/api/stories/[storySlug]/route.ts @@ -10,19 +10,23 @@ export async function GET( { params }: { params: Promise<{ storySlug: string }> } ) { try { - const { userId } = await auth(); + const authResult = await auth(); + const { userId } = authResult; - if (!userId) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } + console.log('API: auth result:', authResult); + console.log('API: userId type:', typeof userId, 'value:', userId); + console.log('API: timestamp:', new Date().toISOString()); const { storySlug: slug } = await params; // Special case: if slug is "all", return user's stories for debugging if (slug === "all") { + if (!userId) { + return NextResponse.json( + { error: "Authentication required for this endpoint" }, + { status: 401 } + ); + } const userStories = await db.select().from(stories).where(eq(stories.userId, userId)); return NextResponse.json({ message: "User stories", @@ -47,14 +51,14 @@ export async function GET( } // Check if the story belongs to the authenticated user - if (result.story.userId !== userId) { - return NextResponse.json( - { error: "Access denied" }, - { status: 403 } - ); - } + const isOwner = userId ? result.story.userId === userId : false; - return NextResponse.json(result); + // Return the story data with ownership information + const responseData = { + ...result, + isOwner, + }; + return NextResponse.json(responseData); } catch (error) { console.error("Error fetching story:", error); return NextResponse.json( diff --git a/app/api/stories/route.ts b/app/api/stories/route.ts index 1f25faf..8d7d7c3 100644 --- a/app/api/stories/route.ts +++ b/app/api/stories/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { db } from "@/lib/db"; import { stories, pages } from "@/lib/schema"; -import { eq } from "drizzle-orm"; +import { eq, desc, sql } from "drizzle-orm"; export async function GET() { try { @@ -15,22 +15,24 @@ export async function GET() { ); } - // Get all stories for the user with their first page + // Get all stories for the user with their pages const userStories = await db .select({ id: stories.id, title: stories.title, slug: stories.slug, + style: stories.style, createdAt: stories.createdAt, pageCount: pages.pageNumber, coverImage: pages.generatedImageUrl, + pageCreatedAt: pages.createdAt, + pageUpdatedAt: pages.updatedAt, }) .from(stories) .leftJoin(pages, eq(stories.id, pages.storyId)) - .where(eq(stories.userId, userId)) - .orderBy(stories.createdAt); + .where(eq(stories.userId, userId)); - // Group by story and find the max page number and first page image + // Group by story and find the max page number, first page image, and most recent update const storyMap = new Map(); userStories.forEach((row) => { @@ -40,9 +42,11 @@ export async function GET() { id: row.id, title: row.title, slug: row.slug, + style: row.style, createdAt: row.createdAt, pageCount: 0, coverImage: null, + lastUpdated: row.createdAt, // Default to story creation date }); } @@ -53,10 +57,23 @@ export async function GET() { if (row.pageCount === 1 && row.coverImage) { story.coverImage = row.coverImage; } + // Track the most recent page update + if (row.pageUpdatedAt && row.pageUpdatedAt > story.lastUpdated) { + story.lastUpdated = row.pageUpdatedAt; + } else if (row.pageCreatedAt && row.pageCreatedAt > story.lastUpdated) { + story.lastUpdated = row.pageCreatedAt; + } }); const storiesWithCovers = Array.from(storyMap.values()); + // Sort by most recently updated (stories with newest pages first) + storiesWithCovers.sort((a, b) => { + const aTime = new Date(a.lastUpdated).getTime(); + const bTime = new Date(b.lastUpdated).getTime(); + return bTime - aTime; // Most recent first + }); + return NextResponse.json({ stories: storiesWithCovers }); diff --git a/app/editor/[storySlug]/page.tsx b/app/editor/[storySlug]/page.tsx index 15b328a..4eeedb2 100644 --- a/app/editor/[storySlug]/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -1,303 +1,48 @@ -"use client" +import { Metadata } from "next"; +import { getStoryWithPagesBySlug } from "@/lib/db-actions"; +import { StoryEditorClient } from "./story-editor-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" +export async function generateMetadata({ + params, +}: { + params: Promise<{ storySlug: string }>; +}): Promise { + const { storySlug: slug } = await params; -import { useS3Upload } from "next-s3-upload" + try { + const result = await getStoryWithPagesBySlug(slug); -interface PageData { - id: number // pageNumber for component compatibility - title: string - image: string - prompt: string - characterUploads?: string[] - style: string - dbId?: string // actual database UUID -} + if (!result) { + return { + title: "Story Not Found | MakeComics", + description: "The requested comic story could not be found.", + }; + } -interface StoryData { - id: string - title: string - description?: string | null - userId?: string | null + const { story } = result; + const title = `${story.title} | MakeComics`; + const description = + story.description || + `${story.title} - Create your own comic book with MakeComics`; + + return { + title, + description, + openGraph: { + title, + description, + type: "website", + }, + }; + } catch (error) { + console.error("Error generating metadata:", error); + return { + title: "MakeComics", + description: "Create your own comic book with MakeComics", + }; + } } export default function StoryEditorPage() { - 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() - - // 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 } = 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, - }))) - - // 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) => { - if (e.key === "ArrowRight") { - setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)) - } else if (e.key === "ArrowLeft") { - setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)) - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [pages.length]) - - const handleAddPage = () => { - const storedKey = localStorage.getItem("together_api_key") - if (!storedKey && pages.length >= 1) { - setShowApiModal(true) - return - } - setShowGenerateModal(true) - } - - const handleContinueStory = () => { - const storedKey = localStorage.getItem("together_api_key") - if (!storedKey) { - setShowApiModal(true) - return - } - setShowGenerateModal(true) - } - - const handleApiKeyClick = () => { - setShowApiModal(true) - } - - const handleApiKeySubmit = (key: string) => { - localStorage.setItem("together_api_key", key) - setShowApiModal(false) - const wasGenerating = showGenerateModal - if (wasGenerating) { - 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 - }) => { - 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") - if (!apiKey) { - throw new Error("API key not found") - } - - const previousPage = pages[pages.length - 1] - - const response = await fetch("/api/generate-comic", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - storyId: story?.id, - prompt: data.prompt, - apiKey, - style: data.style, - characterImages: characterUploads, - isContinuation: data.isContinuation, - previousContext: data.isContinuation ? previousPage?.prompt : undefined, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || "Failed to generate image") - } - - 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, - }) - } catch (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.", - 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) { - return ( -
-
Story not found
-
- ) - } - - return ( -
- setShowInfoSheet(true)} - /> - -
- - -
- - 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} - /> - setShowInfoSheet(false)} page={pages[currentPage]} /> -
- ) -} \ No newline at end of file + return ; +} diff --git a/app/editor/[storySlug]/story-editor-client.tsx b/app/editor/[storySlug]/story-editor-client.tsx new file mode 100644 index 0000000..d2ac2dc --- /dev/null +++ b/app/editor/[storySlug]/story-editor-client.tsx @@ -0,0 +1,453 @@ +"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 { 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 [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 [loadingPageId, setLoadingPageId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [existingCharacterImages, setExistingCharacterImages] = useState< + string[] + >([]); + const { toast } = useToast(); + const [apiKey, setApiKey] = useApiKey(); + + // 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(); + console.log("Editor: full API response:", result); + + const { + story: storyData, + pages: pagesData, + isOwner: ownerStatus, + } = result; + + console.log("Editor: received story data:", storyData); + + 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") { + setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev)); + } else if (e.key === "ArrowLeft") { + setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === "i" || e.key === "I") { + setShowInfoSheet(true); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [pages.length]); + + const handleAddPage = () => { + if (!apiKey && pages.length >= 1) { + setShowApiModal(true); + return; + } + setShowGenerateModal(true); + }; + + const handleRedrawPage = async () => { + if (!apiKey) { + setShowApiModal(true); + return; + } + + 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 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 wasGenerating = showGenerateModal; + if (wasGenerating) { + setShowGenerateModal(true); + } + }; + + 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 + + + + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index bc5add9..6710445 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,8 +3,7 @@ import { Navbar } from "@/components/landing/navbar" import { Footer } from "@/components/landing/footer" import { LandingHero } from "@/components/landing/hero-section" -import { StoryInput } from "@/components/landing/story-input" -import { CreateButton } from "@/components/landing/create-button" +import { ComicCreationForm } from "@/components/landing/comic-creation-form" import { useState, useEffect } from "react" export default function Home() { @@ -43,28 +42,20 @@ export default function Home() {
-
-
- -
-
- -
-
+
+
+ +
+
diff --git a/app/stories/page.tsx b/app/stories/page.tsx index afcffa5..0df4a58 100644 --- a/app/stories/page.tsx +++ b/app/stories/page.tsx @@ -5,14 +5,18 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Plus, Loader2 } from "lucide-react"; import { Navbar } from "@/components/landing/navbar"; +import { StoryLoader } from "@/components/ui/story-loader"; +import { COMIC_STYLES } from "@/lib/constants"; interface Story { id: string; title: string; slug: string; + style: string; createdAt: string; pageCount: number; coverImage: string | null; + lastUpdated?: string; } export default function StoriesPage() { @@ -51,10 +55,7 @@ export default function StoriesPage() {
-
- -

Loading your comic library...

-
+
); @@ -85,78 +86,107 @@ export default function StoriesPage() {
+
+
-
-
-
- {stories.length === 0 ? ( -
-
- -
-

No comics yet

-

- Create your first comic story to build your library! -

-
- ) : ( -
- {stories.map((story) => ( - +
+ ) : ( + <> +
+

+ Your Comic Library +

+

+ Browse and continue your comic creations. Each story is a unique visual narrative waiting to unfold. +

+
- {story.pageCount > 1 && ( -
-
- {story.pageCount > 2 && ( -
- )} -
- )} +
+ {stories.map((story, index) => ( + - ))} -
- )} -
+ {/* Subtle glow effect on hover */} +
+ +
+ {COMIC_STYLES.find(s => s.id === story.style)?.name.toUpperCase() || story.style.toUpperCase()} +
+ +
+

+ {story.title} +

+

+ {new Date(story.createdAt).toLocaleDateString()} +

+
+ + ) : ( +
+
+ +

Generating...

+
+
+ )} +
+ + ))} +
+ + )} +
diff --git a/components/api-key-modal.tsx b/components/api-key-modal.tsx index b1f4d18..cb664c8 100644 --- a/components/api-key-modal.tsx +++ b/components/api-key-modal.tsx @@ -13,6 +13,7 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { TOGETHER_LINK } from "@/lib/utils"; +import { useApiKey } from "@/hooks/use-api-key"; interface ApiKeyModalProps { isOpen: boolean; @@ -21,38 +22,35 @@ interface ApiKeyModalProps { } export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { - const [apiKey, setApiKey] = useState(""); + const [apiKeyInput, setApiKeyInput] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [existingKey, setExistingKey] = useState(null); + const [existingKey, setApiKey] = useApiKey(); useEffect(() => { - if (typeof window !== "undefined" && isOpen) { - const storedKey = localStorage.getItem("together_api_key"); - setExistingKey(storedKey); - setApiKey((current) => { - if (storedKey && current === "") { - return storedKey; + if (isOpen) { + setApiKeyInput((current) => { + if (existingKey && current === "") { + return existingKey; } return current; }); } - }, [isOpen]); + }, [isOpen, existingKey]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!apiKey.trim()) return; + if (!apiKeyInput.trim()) return; setIsLoading(true); await new Promise((resolve) => setTimeout(resolve, 500)); setIsLoading(false); - onSubmit(apiKey.trim()); - setApiKey(""); + onSubmit(apiKeyInput.trim()); + setApiKeyInput(""); }; const handleDelete = () => { - localStorage.removeItem("together_api_key"); - setExistingKey(null); - setApiKey(""); + setApiKey(null); + setApiKeyInput(""); onClose(); }; @@ -83,15 +81,15 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
setApiKey(e.target.value)} + value={apiKeyInput} + onChange={(e) => setApiKeyInput(e.target.value)} placeholder={existingKey ? "Your current API key" : "Enter your API key..."} className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10" /> - {apiKey && ( + {apiKeyInput && (
@@ -44,25 +81,93 @@ export function ComicCanvas({ page }: ComicCanvasProps) {
+ {/* Action buttons below the page image */} +
+ {onInfoClick && ( + + )} + + {isOwner && ( + + )} + + {isOwner && totalPages > 1 && onDeletePage && ( + + )} +
+
{/*
Page {page.id}
*/} {/* Mobile action buttons */}
- + {isOwner && ( + + )}
diff --git a/components/editor/editor-toolbar.tsx b/components/editor/editor-toolbar.tsx index 832c64d..d6ae512 100644 --- a/components/editor/editor-toolbar.tsx +++ b/components/editor/editor-toolbar.tsx @@ -1,17 +1,23 @@ -"use client" +"use client"; -import { ArrowLeft, RefreshCw, Download, Plus, Info } from "lucide-react" -import { Button } from "@/components/ui/button" -import { useRouter } from "next/navigation" +import { ArrowLeft, RefreshCw, Share, Plus, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/hooks/use-toast"; interface EditorToolbarProps { - title: string - onContinueStory: () => void - onInfoClick: () => void + title: string; + onContinueStory?: () => void; + isOwner?: boolean; } -export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToolbarProps) { - const router = useRouter() +export function EditorToolbar({ + title, + onContinueStory, + isOwner = true, +}: EditorToolbarProps) { + const router = useRouter(); + const { toast } = useToast(); return (
@@ -19,50 +25,56 @@ export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToo -

{title}

+

+ {title} +

-
- - +
- - - + {isOwner && onContinueStory && ( + + )}
- ) + ); } diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx index 8451b54..c19c020 100644 --- a/components/editor/generate-page-modal.tsx +++ b/components/editor/generate-page-modal.tsx @@ -1,28 +1,40 @@ -"use client" +"use client"; -import { useState, useRef, useEffect, useMemo } from "react" -import { Upload, X, Loader2 } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { useToast } from "@/hooks/use-toast" -import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils" -import { COMIC_STYLES } from "@/lib/constants" +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"; + +interface CharacterItem { + url: string; + isNew?: boolean; + file?: File; + preview?: string; +} interface GeneratePageModalProps { - isOpen: boolean - onClose: () => void + isOpen: boolean; + onClose: () => void; onGenerate: (data: { - prompt: string - style: string - characterFiles?: File[] - characterUrls?: string[] // For reusing existing characters - isContinuation?: boolean - }) => void - pageNumber: number - previousCharacters?: File[] - previousPagePrompt?: string - previousPageStyle?: string - existingCharacterImages?: string[] // Character images from previous pages + 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({ @@ -30,55 +42,103 @@ export function GeneratePageModal({ onClose, onGenerate, pageNumber, - previousCharacters, - previousPagePrompt, - previousPageStyle, - existingCharacterImages = [], + isRedrawMode = false, + existingPrompt = "", + existingCharacters = [], + lastPageCharacters = [], + previousPageCharacters = [], }: GeneratePageModalProps) { - const [prompt, setPrompt] = useState("") - const [uploadedFiles, setUploadedFiles] = useState(previousCharacters || []) - const [selectedExistingCharacters, setSelectedExistingCharacters] = useState([]) - 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 [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 (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]) + 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); + } } } - reader.readAsDataURL(file) - }) + } + + // 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); } - }, [previousCharacters]) + }, [ + 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 + if (!newFiles) return; - const filesArray = Array.from(newFiles) + const filesArray = Array.from(newFiles); - // Validate files (including WebP rejection) - const validationResults = filesArray.map(file => ({ + const validationResults = filesArray.map((file) => ({ file, - validation: validateFileForUpload(file, true) - })) + validation: validateFileForUpload(file, true), + })); - // Show errors for invalid files validationResults.forEach(({ validation }) => { if (!validation.valid && validation.error) { toast({ @@ -86,255 +146,326 @@ export function GeneratePageModal({ description: validation.error, variant: "destructive", duration: 4000, - }) + }); } - }) + }); const validFiles = validationResults .filter(({ validation }) => validation.valid) - .map(({ file }) => file) + .map(({ file }) => file); - if (validFiles.length === 0) return + if (validFiles.length === 0) return; - const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files - setUploadedFiles(totalFiles) + // 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, + }; + }) + ); - // Generate previews for all files - const newPreviews = await Promise.all( - totalFiles.map((file) => generateFilePreview(file)) - ) - setPreviews(newPreviews) - } + // 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; + }); - const removeFile = (index: number) => { - const newFiles = uploadedFiles.filter((_, i) => i !== index) - const newPreviews = previews.filter((_, i) => i !== index) - setUploadedFiles(newFiles) - setPreviews(newPreviews) - setShowPreview(null) if (fileInputRef.current) { - fileInputRef.current.value = "" + fileInputRef.current.value = ""; } - } + }; - const toggleExistingCharacter = (characterUrl: string) => { - setSelectedExistingCharacters(prev => - prev.includes(characterUrl) - ? prev.filter(url => url !== characterUrl) - : [...prev, characterUrl] - ) - } + 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 handleGenerate = () => { - if (!prompt.trim()) return - setIsGenerating(true) - onGenerate({ - prompt, - style: selectedStyle, - characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined, - isContinuation: false, - }) - } + const removeCharacter = (index: number) => { + setCharacters((prev) => { + const updated = prev.filter((_, i) => i !== index); - 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, - }) - } + // 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; + }); - useEffect(() => { - if (!isOpen) { - setIsGenerating(false) - setIsContinuing(false) - setPrompt("") + 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); + toast({ + title: "Generation failed", + description: + error instanceof Error + ? error.message + : "Failed to generate page. Please try again.", + variant: "destructive", + duration: 4000, + }); + setIsGenerating(false); } - }, [isOpen]) + }; + + const handleOpenChange = (open: boolean) => { + // Prevent closing the modal if generation is running + if (!open && isGenerating) { + return; + } + onClose(); + }; return ( <> - + - Generate Page {pageNumber} + + {isRedrawMode + ? `Redraw Page ${pageNumber}` + : `Generate Page ${pageNumber}`} + + + + Close +
+ {/* Prompt Input */}
-
- {selectedStyle} -