From 0710982f9d86c855f387b229552baddc8cacf790 Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Fri, 26 Dec 2025 21:31:30 +0100 Subject: [PATCH] Add AI-generated title and description for new comic stories --- app/api/generate-comic/route.ts | 114 +++++- app/editor/[storySlug]/page.tsx | 370 ++---------------- .../[storySlug]/story-editor-client.tsx | 337 ++++++++++++++++ lib/db-actions.ts | 6 + 4 files changed, 496 insertions(+), 331 deletions(-) create mode 100644 app/editor/[storySlug]/story-editor-client.tsx diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index d3ad1f1..abc512b 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -3,6 +3,7 @@ import Together from "together-ai"; import { auth } from "@clerk/nextjs/server"; import { updatePage, + updateStory, createStory, createPage, getNextPageNumber, @@ -25,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(); @@ -121,6 +124,7 @@ export async function POST(request: NextRequest) { referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2 } 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, @@ -151,6 +155,85 @@ export async function POST(request: NextRequest) { 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({ @@ -159,7 +242,8 @@ export async function POST(request: NextRequest) { width: dimensions.width, height: dimensions.height, temperature: 0.1, // Lower temperature for more consistent face matching - reference_images: referenceImages.length > 0 ? referenceImages : undefined, + reference_images: + referenceImages.length > 0 ? referenceImages : undefined, }); } catch (error) { console.error("Together AI API error:", error); @@ -205,9 +289,33 @@ export async function POST(request: NextRequest) { 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); @@ -227,6 +335,8 @@ export async function POST(request: NextRequest) { 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/editor/[storySlug]/page.tsx b/app/editor/[storySlug]/page.tsx index 57e3594..4eeedb2 100644 --- a/app/editor/[storySlug]/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -1,336 +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 { 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"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ storySlug: string }>; +}): Promise { + const { storySlug: slug } = await params; -interface PageData { - id: number; // pageNumber for component compatibility - title: string; - image: string; - prompt: string; - characterUploads?: string[]; - style: string; - dbId?: string; // actual database UUID -} + try { + const result = await getStoryWithPagesBySlug(slug); -interface StoryData { - id: string; - slug: string; - title: string; - description?: string | null; - style: string; - userId?: string | null; - isOwner?: boolean; + if (!result) { + return { + title: "Story Not Found | MakeComics", + description: "The requested comic story could not be found.", + }; + } + + 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 [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 [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) => { - 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 = () => { - 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 handleApiKeySubmit = (key: string) => { - setApiKey(key); - setShowApiModal(false); - const wasGenerating = showGenerateModal; - if (wasGenerating) { - setShowGenerateModal(true); - } - }; - - const handleGeneratePage = async (data: { - prompt: string; - characterFiles?: File[]; - characterUrls?: string[]; - }) => { - try { - if (!apiKey) { - setShowApiModal(true); - return; - } - - // 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(); - - 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); - } catch (error) { - console.error("Error generating page:", error); - toast({ - title: "Failed to generate page", - description: - error instanceof Error ? error.message : "Failed to generate page", - variant: "destructive", - duration: 4000, - }); - } - }; - - if (isLoading) { - return ( -
- -
- ); - } - - if (!story) { - return ( -
-
Story not found
-
- ); - } - - return ( -
- - -
- - setShowInfoSheet(true)} - onRedrawClick={handleRedrawPage} - 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} - /> - setShowInfoSheet(false)} - page={pages[currentPage]} - /> -
- ); + 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..8f811a9 --- /dev/null +++ b/app/editor/[storySlug]/story-editor-client.tsx @@ -0,0 +1,337 @@ +"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"; + +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 [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) => { + 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 = () => { + 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 handleApiKeySubmit = (key: string) => { + setApiKey(key); + setShowApiModal(false); + const wasGenerating = showGenerateModal; + if (wasGenerating) { + setShowGenerateModal(true); + } + }; + + const handleGeneratePage = async (data: { + prompt: string; + characterFiles?: File[]; + characterUrls?: string[]; + }) => { + try { + if (!apiKey) { + setShowApiModal(true); + return; + } + + // 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(); + + 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); + } catch (error) { + console.error("Error generating page:", error); + toast({ + title: "Failed to generate page", + description: + error instanceof Error ? error.message : "Failed to generate page", + variant: "destructive", + duration: 4000, + }); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!story) { + return ( +
+
Story not found
+
+ ); + } + + return ( +
+ + +
+ + setShowInfoSheet(true)} + onRedrawClick={handleRedrawPage} + 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} + /> + setShowInfoSheet(false)} + page={pages[currentPage]} + /> +
+ ); +} + diff --git a/lib/db-actions.ts b/lib/db-actions.ts index 54e797d..dcc9c06 100644 --- a/lib/db-actions.ts +++ b/lib/db-actions.ts @@ -42,6 +42,12 @@ export async function updatePage(pageId: string, generatedImageUrl: string): Pro .where(eq(pages.id, pageId)); } +export async function updateStory(storyId: string, data: { title?: string; description?: string }): Promise { + await db.update(stories) + .set({ ...data, updatedAt: new Date() }) + .where(eq(stories.id, storyId)); +} + export async function getStoryWithPages(storyId: string): Promise<{ story: Story; pages: Page[] } | null> { const storyResult = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1);