From 66b252fb2ef5ce5ac283813cab7b8f2203cf1114 Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Fri, 26 Dec 2025 12:15:01 +0100 Subject: [PATCH] Add loading state and style constants, enhance API key modal with delete --- app/api/generate-comic/route.ts | 120 +++------------ app/api/stories/[storySlug]/route.ts | 1 - app/editor/[storySlug]/page.tsx | 18 ++- app/page.tsx | 10 +- components/api-key-modal.tsx | 40 +++-- components/editor/generate-page-modal.tsx | 16 +- components/editor/page-info-sheet.tsx | 57 +++++--- components/landing/create-button.tsx | 5 +- components/landing/navbar.tsx | 17 ++- components/landing/story-input.tsx | 39 +++-- components/landing/style-selector.tsx | 35 +++-- drizzle/0004_real_maggott.sql | 2 + drizzle/meta/0004_snapshot.json | 171 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/constants.ts | 22 +++ lib/db-actions.ts | 14 +- lib/schema.ts | 3 +- 17 files changed, 379 insertions(+), 198 deletions(-) create mode 100644 drizzle/0004_real_maggott.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 lib/constants.ts diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index 008778a..3bd8ee3 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -6,102 +6,21 @@ import { createStory, createPage, getNextPageNumber, + getStoryById, } from "@/lib/db-actions"; import { freeTierRateLimit } from "@/lib/rate-limit"; +import { COMIC_STYLES } from "@/lib/constants"; 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 }; -async function analyzeCharacterImage( - imageBase64: string, - apiKey: string, - characterNumber: number -): Promise { - try { - // Clean base64 string - const base64Data = imageBase64.replace(/^data:image\/[^;]+;base64,/, ""); - - const response = await fetch( - "https://api.together.xyz/v1/chat/completions", - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: "meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo", - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: `Analyze this person for a comic book character reference. Provide a detailed physical description in one paragraph. Include: -- Gender and approximate age -- Face shape (round, oval, square, etc.) -- Hair: color, length, style, texture -- Eye color and shape -- Skin tone -- Body type/build -- Any distinctive features (glasses, facial hair, freckles, etc.) -- Current outfit/clothing style and colors - -Be VERY specific and detailed. This description will be used to draw this exact person as a comic character. Respond ONLY with the physical description, no other text.`, - }, - { - type: "image_url", - image_url: { - url: `data:image/jpeg;base64,${base64Data}`, - }, - }, - ], - }, - ], - max_tokens: 500, - temperature: 0.3, - }), - } - ); - - if (!response.ok) { - console.error( - `Vision API error for character ${characterNumber}:`, - await response.text() - ); - return `Character ${characterNumber}`; - } - - const data = await response.json(); - const description = - data.choices?.[0]?.message?.content || `Character ${characterNumber}`; - console.log(`Character ${characterNumber} description:`, description); - return description; - } catch (error) { - console.error(`Error analyzing character ${characterNumber}:`, error); - return `Character ${characterNumber}`; - } -} - -const STYLE_DESCRIPTIONS: Record = { - noir: "film noir style, high contrast black and white, deep dramatic shadows, 1940s detective aesthetic, heavy bold inking, moody atmospheric lighting", - manga: - "Japanese manga style, clean precise black linework, screen tone shading, expressive eyes, dynamic speed lines, black and white with impact effects", - superhero: - "classic American superhero comic style, bold vibrant colors, dynamic heroic poses, detailed muscular anatomy, Jim Lee and Jack Kirby inspired", - vintage: - "Golden Age 1950s comic style, visible halftone Ben-Day dots, limited retro color palette, nostalgic warm tones, classic adventure comics", - modern: - "contemporary digital comic art, smooth gradient coloring, detailed realistic backgrounds, cinematic widescreen composition, graphic novel quality", - watercolor: - "painted watercolor comic style, soft blended edges, flowing artistic colors, delicate linework with painted fills, ethereal atmosphere", -}; - export async function POST(request: NextRequest) { try { const { userId } = await auth(); @@ -123,8 +42,6 @@ export async function POST(request: NextRequest) { previousContext = "", } = await request.json(); - console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId, hasApiKey: !!apiKey }); - if (!prompt) { return NextResponse.json( { error: "Missing required fields" }, @@ -142,7 +59,9 @@ export async function POST(request: NextRequest) { if (!success) { const resetDate = new Date(reset); - const timeUntilReset = Math.ceil((reset - Date.now()) / (1000 * 60 * 60 * 24)); // days + const timeUntilReset = Math.ceil( + (reset - Date.now()) / (1000 * 60 * 60 * 24) + ); // days return NextResponse.json( { @@ -158,7 +77,9 @@ export async function POST(request: NextRequest) { finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT; if (!finalApiKey) { return NextResponse.json( - { error: "Server configuration error - default API key not available" }, + { + error: "Server configuration error - default API key not available", + }, { status: 500 } ); } @@ -168,39 +89,37 @@ export async function POST(request: NextRequest) { let story; if (storyId) { - // Create page for existing story - console.log("Creating page for existing story:", storyId); + const story = await getStoryById(storyId); + if (!story) { + return NextResponse.json({ error: "Story not found" }, { status: 404 }); + } + const nextPageNumber = await getNextPageNumber(storyId); page = await createPage({ storyId, pageNumber: nextPageNumber, prompt, characterImageUrls: characterImages, - style, }); - console.log("Page created:", page.id); } else { - // Create new story and first page - console.log("Creating new story for user:", userId); story = await createStory({ title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt, description: undefined, userId: userId, + style, }); - console.log("Story created:", story.id); page = await createPage({ storyId: story.id, pageNumber: 1, prompt, characterImageUrls: characterImages, - style, }); - console.log("First page created:", page.id); } const dimensions = FIXED_DIMENSIONS; - const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir; + const styleInfo = COMIC_STYLES.find((s) => s.id === style); + const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt; const continuationContext = isContinuation && previousContext @@ -268,8 +187,6 @@ COMPOSITION: const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`; - console.log("Generating comic with prompt length:", fullPrompt.length, "using tier:", isUsingFreeTier ? "free" : "paid"); - const client = new Together({ apiKey: finalApiKey }); let response; @@ -329,7 +246,6 @@ COMPOSITION: // Update page in database try { await updatePage(page.id, imageUrl); - console.log("Page updated with image:", page.id); } catch (dbError) { console.error("Error updating page in database:", dbError); return NextResponse.json( diff --git a/app/api/stories/[storySlug]/route.ts b/app/api/stories/[storySlug]/route.ts index 580d5f4..fcc9984 100644 --- a/app/api/stories/[storySlug]/route.ts +++ b/app/api/stories/[storySlug]/route.ts @@ -20,7 +20,6 @@ export async function GET( } const { storySlug: slug } = await params; - console.log("API: Fetching story with slug:", slug, "for user:", userId); // Special case: if slug is "all", return user's stories for debugging if (slug === "all") { diff --git a/app/editor/[storySlug]/page.tsx b/app/editor/[storySlug]/page.tsx index 6684543..15b328a 100644 --- a/app/editor/[storySlug]/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -64,7 +64,7 @@ export default function StoryEditorPage() { image: page.generatedImageUrl || "", prompt: page.prompt, characterUploads: page.characterImageUrls, - style: "noir", + style: storyData.style || "noir", dbId: page.id, }))) @@ -76,7 +76,7 @@ export default function StoryEditorPage() { console.error("Error loading story:", error) toast({ title: "Error loading story", - description: "Failed to load the story data.", + description: "Failed to load story data.", variant: "destructive", duration: 4000, }) @@ -90,6 +90,20 @@ export default function StoryEditorPage() { } }, [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) { diff --git a/app/page.tsx b/app/page.tsx index d534410..bc5add9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,7 @@ export default function Home() { const [prompt, setPrompt] = useState("") const [style, setStyle] = useState("noir") const [characterFiles, setCharacterFiles] = useState([]) + const [isLoading, setIsLoading] = useState(false) // Auto-loop through pages every 6 seconds useEffect(() => { @@ -51,10 +52,17 @@ export default function Home() { setStyle={setStyle} characterFiles={characterFiles} setCharacterFiles={setCharacterFiles} + isLoading={isLoading} />
- +
diff --git a/components/api-key-modal.tsx b/components/api-key-modal.tsx index e4dcc0e..b1f4d18 100644 --- a/components/api-key-modal.tsx +++ b/components/api-key-modal.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useState, useEffect } from "react"; -import { Key, ExternalLink, ArrowRight } from "lucide-react"; +import { Key, ExternalLink, ArrowRight, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -49,6 +49,13 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { setApiKey(""); }; + const handleDelete = () => { + localStorage.removeItem("together_api_key"); + setExistingKey(null); + setApiKey(""); + onClose(); + }; + return ( @@ -67,19 +74,30 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { {existingKey - ? "Update your Together API key or add a new one." + ? "Update your Together API key or add a new one. You can also delete your existing key." : "Your first page was free! Add your Together API key to generate more pages."}
- setApiKey(e.target.value)} - placeholder="Enter your API key..." - className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5" - /> +
+ setApiKey(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 && ( + + )} +
- Maybe Later + {existingKey ? "Delete API Key" : "Maybe Later"} @@ -128,8 +132,9 @@ export function StoryInput({ ))} {characterFiles.length < 2 && ( @@ -137,8 +142,9 @@ export function StoryInput({ ) : (