diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/README.md b/README.md index 7ab1534..c31a6ca 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# make-comics \ No newline at end of file +# make-comics diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts new file mode 100644 index 0000000..e85440a --- /dev/null +++ b/app/api/generate-comic/route.ts @@ -0,0 +1,227 @@ +import { type NextRequest, NextResponse } from "next/server" + +const FIXED_DIMENSIONS = { width: 864, height: 1184 } + +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", +} + +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(`[v0] 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(`[v0] Character ${characterNumber} description:`, description) + return description + } catch (error) { + console.error(`[v0] Error analyzing character ${characterNumber}:`, error) + return `Character ${characterNumber}` + } +} + +export async function POST(request: NextRequest) { + try { + const { + prompt, + apiKey, + style = "noir", + characterImages = [], + isContinuation = false, + previousContext = "", + } = await request.json() + + if (!prompt || !apiKey) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) + } + + const dimensions = FIXED_DIMENSIONS + const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir + + 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) { + console.log(`[v0] Analyzing ${characterImages.length} character image(s)...`) + + const characterDescriptions = await Promise.all( + characterImages.map((img: string, index: number) => analyzeCharacterImage(img, apiKey, index + 1)), + ) + + if (characterImages.length === 1) { + characterSection = ` +MAIN CHARACTER (MUST APPEAR IN EVERY PANEL): +${characterDescriptions[0]} + +CRITICAL INSTRUCTIONS: +- This EXACT character must appear in ALL 5 panels +- Draw them in ${style} comic art style but keep their EXACT appearance +- Same face, same hair, same outfit, same features in every panel +- They are the PROTAGONIST - center of every scene` + } else if (characterImages.length === 2) { + characterSection = ` +TWO MAIN CHARACTERS (BOTH MUST APPEAR TOGETHER IN MOST PANELS): + +CHARACTER 1 - "FIRST PERSON": +${characterDescriptions[0]} + +CHARACTER 2 - "SECOND PERSON": +${characterDescriptions[1]} + +CRITICAL INSTRUCTIONS: +- BOTH characters must appear together in at least 4 of the 5 panels +- Keep them VISUALLY DISTINCT - do not mix up their features +- CHARACTER 1 and CHARACTER 2 are DIFFERENT PEOPLE with different appearances +- If one is female and one is male, keep their genders correct +- Draw both in ${style} comic art style but preserve their EXACT individual appearances +- Each character must be immediately recognizable in every panel they appear +- They are the two protagonists interacting with each other throughout the story` + } + } + + const systemPrompt = `Professional comic book page illustration. +${continuationContext} +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 requestBody = { + model: "google/flash-image-2.5", + prompt: fullPrompt, + width: dimensions.width, + height: dimensions.height, + n: 1, + } + + console.log("[v0] Generating comic with prompt length:", fullPrompt.length) + + const response = await fetch("https://api.together.xyz/v1/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + const errorData = await response.json() + console.error("[v0] Together AI API error:", errorData) + + if (response.status === 402) { + return NextResponse.json( + { + error: + "Insufficient API credits. Please add credits to your Together.ai account at https://api.together.ai/settings/billing or update your API key.", + errorType: "credit_limit", + }, + { status: 402 }, + ) + } + + return NextResponse.json( + { + error: errorData.error?.message || `Failed to generate image: ${response.statusText}`, + errorType: errorData.error?.type || "api_error", + }, + { status: response.status }, + ) + } + + const data = await response.json() + + if (!data.data || !data.data[0] || !data.data[0].url) { + return NextResponse.json({ error: "No image URL in response" }, { status: 500 }) + } + + return NextResponse.json({ imageUrl: data.data[0].url }) + } catch (error) { + console.error("[v0] Error in generate-comic API:", error) + return NextResponse.json( + { error: `Internal server error: ${error instanceof Error ? error.message : "Unknown error"}` }, + { status: 500 }, + ) + } +} diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..7360330 --- /dev/null +++ b/app/editor/page.tsx @@ -0,0 +1,240 @@ +"use client" + +import { useState, useEffect } from "react" +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" + +interface PageData { + id: number + title: string + image: string + prompt: string + characterUploads?: string[] + style: string +} + +const DEMO_PAGES: PageData[] = [ + { + id: 1, + title: "Redwing: Guardian of NYC", + image: "/comic-book-superhero-action-scene-noir-style-dark-.jpg", + prompt: + "A superhero named Redwing protects NYC from the shadows. Tonight, a new villain threatens the city with stolen tech...", + style: "Noir", + }, +] + +export default function EditorPage() { + const [pages, setPages] = useState(DEMO_PAGES) + 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 [lastCharacterFiles, setLastCharacterFiles] = useState([]) + const [lastCharacterUploads, setLastCharacterUploads] = useState([]) + const { toast } = useToast() + + useEffect(() => { + const firstPageData = sessionStorage.getItem("firstPageData") + if (firstPageData) { + const data = JSON.parse(firstPageData) + setPages([ + { + ...pages[0], + prompt: data.prompt, + style: data.style, + image: data.imageUrl || pages[0].image, + characterUploads: data.characterUploads, + }, + ]) + + if (data.characterUploads && data.characterUploads.length > 0) { + setLastCharacterUploads(data.characterUploads) + } + + sessionStorage.removeItem("firstPageData") + } + + toast({ + title: "Comic generated successfully", + description: "Your comic page is ready to view", + duration: 4000, + }) + }, [toast]) + + 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[] + isContinuation?: boolean + }) => { + setShowGenerateModal(false) + + const newPageId = pages.length + 1 + + let characterUploads: string[] = [] + if (data.characterFiles && data.characterFiles.length > 0) { + characterUploads = await Promise.all(data.characterFiles.map((file) => fileToBase64(file))) + setLastCharacterUploads(characterUploads) + } + + const newPage: PageData = { + id: newPageId, + title: pages[0].title, + image: "", + prompt: data.prompt, + characterUploads: characterUploads.length > 0 ? characterUploads : undefined, + style: data.style, + } + + setPages([...pages, newPage]) + setCurrentPage(pages.length) + setLoadingPageId(newPageId) + setLastCharacterFiles(data.characterFiles || []) + + try { + 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({ + prompt: data.prompt, + apiKey: apiKey, + style: data.style, + isContinuation: data.isContinuation, + previousContext: data.isContinuation ? previousPage?.prompt : undefined, + }), + }) + + if (!response.ok) { + throw new Error("Failed to generate image") + } + + const result = await response.json() + + setPages((prevPages) => + prevPages.map((page) => + page.id === newPageId + ? { + ...page, + image: result.imageUrl, + } + : page, + ), + ) + + toast({ + title: "Page generated successfully", + description: `Page ${newPageId} is ready`, + duration: 4000, + }) + } catch (error) { + console.error("[v0] Error generating page:", error) + toast({ + title: "Generation failed", + description: "Failed to generate comic page. Please try again.", + variant: "destructive", + duration: 4000, + }) + + setPages((prevPages) => prevPages.filter((page) => page.id !== newPageId)) + setCurrentPage(Math.max(0, pages.length - 1)) + } finally { + setLoadingPageId(null) + } + } + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file) + }) + } + + return ( +
+ setShowInfoSheet(true)} + /> + +
+ + +
+ + setShowApiModal(false)} onSubmit={handleApiKeySubmit} /> + setShowGenerateModal(false)} + onGenerate={handleGeneratePage} + pageNumber={pages.length + 1} + previousCharacters={lastCharacterFiles} + previousPagePrompt={pages[pages.length - 1]?.prompt} + previousPageStyle={pages[pages.length - 1]?.style?.toLowerCase()} + /> + setShowInfoSheet(false)} page={pages[currentPage]} /> +
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..f1fda99 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,247 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* Switched to dark mode as default with indigo accent palette */ +:root { + --background: oklch(0.08 0.005 270); + --foreground: oklch(0.9 0.01 250); + --card: oklch(0.12 0.005 270); + --card-foreground: oklch(0.95 0 0); + --popover: oklch(0.12 0.005 270); + --popover-foreground: oklch(0.95 0 0); + --primary: oklch(0.95 0 0); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.18 0.01 270); + --secondary-foreground: oklch(0.85 0 0); + --muted: oklch(0.18 0.005 270); + --muted-foreground: oklch(0.55 0.01 250); + --accent: oklch(0.6 0.2 270); + --accent-foreground: oklch(0.98 0 0); + --destructive: oklch(0.55 0.2 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.22 0.01 270); + --input: oklch(0.15 0.005 270); + --ring: oklch(0.6 0.2 270); + --chart-1: oklch(0.6 0.2 270); + --chart-2: oklch(0.65 0.15 180); + --chart-3: oklch(0.7 0.18 80); + --chart-4: oklch(0.6 0.2 300); + --chart-5: oklch(0.65 0.2 20); + --radius: 0.5rem; + --sidebar: oklch(0.1 0.005 270); + --sidebar-foreground: oklch(0.95 0 0); + --sidebar-primary: oklch(0.6 0.2 270); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.18 0.01 270); + --sidebar-accent-foreground: oklch(0.95 0 0); + --sidebar-border: oklch(0.2 0.01 270); + --sidebar-ring: oklch(0.6 0.2 270); + + /* Accent colors */ + --indigo: oklch(0.6 0.2 270); + --indigo-light: oklch(0.7 0.15 270); + --emerald: oklch(0.65 0.2 160); +} + +@theme inline { + /* Adding tight tracking to Inter font for consistency */ + --font-sans: "Inter", sans-serif; + --font-display: "Atma", cursive; + --font-heading: "Instrument Serif", serif; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + /* Custom accent tokens */ + --color-indigo: var(--indigo); + --color-indigo-light: var(--indigo-light); + --color-emerald: var(--emerald); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + /* Adding tight tracking to body text */ + @apply bg-background text-foreground tracking-tight; + } +} + +/* Glass panel effect for cards */ +.glass-panel { + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.glass-panel-hover:hover { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.05); +} + +/* Gradient text effect */ +.gradient-text { + background: linear-gradient(to right, #e5e5e5, #a3a3a3); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* Subtle scan line animation */ +@keyframes scan { + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(100%); + } +} + +.scan-line { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(to bottom, transparent, rgba(99, 102, 241, 0.1), transparent); + animation: scan 4s linear infinite; + pointer-events: none; +} + +/* Dot grid background */ +.dot-grid { + background-image: radial-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px); + background-size: 24px 24px; +} + +.font-display { + font-family: "Atma", cursive; +} + +/* Smooth fade-in animation */ +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in-up { + animation: fade-in-up 0.5s ease-out forwards; +} + +.animation-delay-100 { + animation-delay: 100ms; +} +.animation-delay-200 { + animation-delay: 200ms; +} +.animation-delay-300 { + animation-delay: 300ms; +} +.animation-delay-400 { + animation-delay: 400ms; +} +.animation-delay-500 { + animation-delay: 500ms; +} +.animation-delay-700 { + animation-delay: 700ms; +} +.animation-delay-1000 { + animation-delay: 1000ms; +} + +/* Floating animation for comic panels */ +@keyframes float { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 50% { + transform: translateY(-10px) rotate(1deg); + } +} + +@keyframes float-delayed { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 50% { + transform: translateY(-8px) rotate(-1deg); + } +} + +.animate-float { + animation: float 6s ease-in-out infinite; +} + +.animate-float-delayed { + animation: float-delayed 7s ease-in-out infinite; + animation-delay: 1s; +} + +/* Custom toast styling to match glass-panel design */ +[data-sonner-toast] { + background: rgba(255, 255, 255, 0.03) !important; + backdrop-filter: blur(10px) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + color: var(--foreground) !important; +} + +[data-sonner-toast][data-type="success"] { + border-color: rgba(52, 211, 153, 0.2) !important; +} + +[data-sonner-toast] [data-title] { + color: var(--foreground) !important; + font-weight: 500 !important; +} + +[data-sonner-toast] [data-description] { + color: var(--muted-foreground) !important; +} + +[data-sonner-toast] [data-close-button] { + border-color: rgba(255, 255, 255, 0.08) !important; + background: rgba(255, 255, 255, 0.05) !important; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ecaec3d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,48 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter, Atma, Space_Grotesk, Instrument_Serif } from "next/font/google" +import { Analytics } from "@vercel/analytics/next" +import { Toaster } from "@/components/ui/toaster" +import "./globals.css" + +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }) +const atma = Atma({ + weight: ["400", "500", "600", "700"], + subsets: ["latin"], + variable: "--font-atma", +}) +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-space-grotesk", +}) +const instrumentSerif = Instrument_Serif({ + weight: ["400"], + subsets: ["latin"], + variable: "--font-instrument-serif", +}) + +export const metadata: Metadata = { + title: "MakeComics - AI Comic Generator", + description: + "Create stunning AI-generated comics in seconds. Choose your style, describe your story, and watch the magic happen.", + generator: 'v0.app' +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..fdde83a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,228 @@ +"use client" + +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 { useState, useEffect } from "react" + +export default function Home() { + const [currentPage, setCurrentPage] = useState(1) + const [prompt, setPrompt] = useState("") + const [style, setStyle] = useState("noir") + const [characterFiles, setCharacterFiles] = useState([]) + + // Auto-loop through pages every 6 seconds + useEffect(() => { + const interval = setInterval(() => { + setCurrentPage((prev) => (prev === 3 ? 1 : prev + 1)) + }, 6000) + + return () => clearInterval(interval) + }, []) + + const goToPage = (page: number) => { + setCurrentPage(page) + } + + return ( +
+ {/* Background gradient blurs */} +
+
+
+
+ + + +
+ {/* Left: Controls & Input */} +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + {/* Right: Visual Preview / Canvas */} +
+ {/* Dot grid background */} +
+ +
+ {/* Background floating comics - less prominent */} +
+
+
+ Background comic +
+
+
+ +
+
+
+ Background comic +
+
+
+ +
+
+
+ Background comic +
+
+
+ +
+
+
+ Background comic +
+
+
+ +
+
+
+ {/* Page transition container */} +
+ {/* Page 1 */} +
+ Comic preview page 1 +
+
+ Page 1 +
+
+ {'"The city needs a hero..."'} +
+
+ + {/* Page 2 */} +
+ Comic preview page 2 +
+
+ Page 2 +
+
+ {'"And a hero shall rise!"'} +
+
+ +
+ Comic preview page 3 +
+
+ Page 3 +
+
+ {'"The battle begins!"'} +
+
+
+
+
+
+ +
+ + + +
+
+
+
+ +
+
+ ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/api-key-modal.tsx b/components/api-key-modal.tsx new file mode 100644 index 0000000..99e835c --- /dev/null +++ b/components/api-key-modal.tsx @@ -0,0 +1,110 @@ +"use client" + +import type React from "react" +import { useState, useEffect } from "react" +import { Key, ExternalLink, ArrowRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" + +interface ApiKeyModalProps { + isOpen: boolean + onClose: () => void + onSubmit: (key: string) => void +} + +export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { + const [apiKey, setApiKey] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [existingKey, setExistingKey] = useState(null) + + useEffect(() => { + if (typeof window !== "undefined" && isOpen) { + const storedKey = localStorage.getItem("together_api_key") + setExistingKey(storedKey) + if (storedKey && apiKey === "") { + setApiKey(storedKey) + } + } + }, [isOpen]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!apiKey.trim()) return + + setIsLoading(true) + await new Promise((resolve) => setTimeout(resolve, 500)) + setIsLoading(false) + onSubmit(apiKey.trim()) + setApiKey("") + } + + return ( + + + +
+
+ +
+
+ + + {existingKey ? "Update your API key" : "Add your API key to continue"} + + + + {existingKey + ? "Update your Together API key or add a new one." + : "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" + /> + + + Get your free API key + + + +
+ + +
+
+ +
+

+ Your API key is stored locally and never sent to our servers. +

+
+
+
+ ) +} diff --git a/components/editor/comic-canvas.tsx b/components/editor/comic-canvas.tsx new file mode 100644 index 0000000..e4ba353 --- /dev/null +++ b/components/editor/comic-canvas.tsx @@ -0,0 +1,69 @@ +"use client" + +import { RefreshCw, Download } from "lucide-react" +import { Button } from "@/components/ui/button" + +interface PageData { + id: number + title: string + image: string + prompt: string + characterUpload?: string + style: string +} + +interface ComicCanvasProps { + page: PageData +} + +export function ComicCanvas({ page }: ComicCanvasProps) { + return ( +
+ {/* Dot grid background */} +
+ +
+
+
+
+ {`Page +
+
+ + {/* Page label */} +
+ Page {page.id} +
+
+
+ +
+
Page {page.id}
+ + {/* Mobile action buttons */} +
+ + + +
+
+
+
+ ) +} diff --git a/components/editor/editor-toolbar.tsx b/components/editor/editor-toolbar.tsx new file mode 100644 index 0000000..a130b45 --- /dev/null +++ b/components/editor/editor-toolbar.tsx @@ -0,0 +1,68 @@ +"use client" + +import { ArrowLeft, RefreshCw, Download, Plus, Info } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" + +interface EditorToolbarProps { + title: string + onContinueStory: () => void + onInfoClick: () => void +} + +export function EditorToolbar({ title, onContinueStory, onInfoClick }: EditorToolbarProps) { + const router = useRouter() + + return ( +
+
+ + +

{title}

+
+ +
+ + + + + + + +
+
+ ) +} diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx new file mode 100644 index 0000000..f1086ff --- /dev/null +++ b/components/editor/generate-page-modal.tsx @@ -0,0 +1,272 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { Upload, X, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" + +const COMIC_STYLES = [ + { id: "american-modern", name: "American Modern" }, + { id: "manga", name: "Manga" }, + { id: "noir", name: "Noir" }, + { id: "vintage", name: "Vintage" }, +] + +interface GeneratePageModalProps { + isOpen: boolean + onClose: () => void + onGenerate: (data: { + prompt: string + style: string + characterFiles?: File[] + isContinuation?: boolean + }) => void + pageNumber: number + previousCharacters?: File[] + previousPagePrompt?: string + previousPageStyle?: string +} + +export function GeneratePageModal({ + isOpen, + onClose, + onGenerate, + pageNumber, + previousCharacters, + previousPagePrompt, + previousPageStyle, +}: GeneratePageModalProps) { + const [prompt, setPrompt] = useState("") + const [uploadedFiles, setUploadedFiles] = useState(previousCharacters || []) + const [previews, setPreviews] = useState([]) + const [showPreview, setShowPreview] = useState(null) + const [isGenerating, setIsGenerating] = useState(false) + const [isContinuing, setIsContinuing] = useState(false) + const fileInputRef = useRef(null) + + const selectedStyle = previousPageStyle || "noir" + + 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]) + } + } + reader.readAsDataURL(file) + }) + } + }, [previousCharacters]) + + const handleFiles = (newFiles: FileList | null) => { + if (!newFiles) return + + const validFiles = Array.from(newFiles).filter((file) => file.type.startsWith("image/")) + const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files + + setUploadedFiles(totalFiles) + + const newPreviews: string[] = [] + totalFiles.forEach((file, index) => { + const reader = new FileReader() + reader.onload = (e) => { + newPreviews[index] = e.target?.result as string + if (newPreviews.filter(Boolean).length === totalFiles.length) { + setPreviews([...newPreviews]) + } + } + reader.readAsDataURL(file) + }) + } + + const removeFile = (index: number) => { + const newFiles = uploadedFiles.filter((_, i) => i !== index) + const newPreviews = previews.filter((_, i) => i !== index) + setUploadedFiles(newFiles) + setPreviews(newPreviews) + setShowPreview(null) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + + const handleGenerate = () => { + if (!prompt.trim()) return + setIsGenerating(true) + onGenerate({ + prompt, + style: selectedStyle, + characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, + isContinuation: false, + }) + } + + 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, + isContinuation: true, + }) + } + + useEffect(() => { + if (!isOpen) { + setIsGenerating(false) + setIsContinuing(false) + setPrompt("") + } + }, [isOpen]) + + return ( + <> + + + + Generate Page {pageNumber} + + +
+
+
+
+ +
+ {selectedStyle} +
+
+ +