initial commit with Youssef work

This commit is contained in:
Riccardo Giorato
2025-12-22 20:20:39 +01:00
parent c0228d67d0
commit 3731e3f476
106 changed files with 12531 additions and 1 deletions
+227
View File
@@ -0,0 +1,227 @@
import { type NextRequest, NextResponse } from "next/server"
const FIXED_DIMENSIONS = { width: 864, height: 1184 }
const STYLE_DESCRIPTIONS: Record<string, string> = {
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<string> {
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 },
)
}
}
+240
View File
@@ -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<PageData[]>(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<number | null>(null)
const [lastCharacterFiles, setLastCharacterFiles] = useState<File[]>([])
const [lastCharacterUploads, setLastCharacterUploads] = useState<string[]>([])
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<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
return (
<div className="h-screen flex flex-col bg-background">
<EditorToolbar
title={pages[0]?.title || "Untitled Comic"}
onContinueStory={handleContinueStory}
onInfoClick={() => setShowInfoSheet(true)}
/>
<div className="flex-1 flex overflow-hidden">
<PageSidebar
pages={pages}
currentPage={currentPage}
onPageSelect={setCurrentPage}
onAddPage={handleAddPage}
loadingPageId={loadingPageId}
onApiKeyClick={handleApiKeyClick}
/>
<ComicCanvas page={pages[currentPage]} />
</div>
<ApiKeyModal isOpen={showApiModal} onClose={() => setShowApiModal(false)} onSubmit={handleApiKeySubmit} />
<GeneratePageModal
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
onGenerate={handleGeneratePage}
pageNumber={pages.length + 1}
previousCharacters={lastCharacterFiles}
previousPagePrompt={pages[pages.length - 1]?.prompt}
previousPageStyle={pages[pages.length - 1]?.style?.toLowerCase()}
/>
<PageInfoSheet isOpen={showInfoSheet} onClose={() => setShowInfoSheet(false)} page={pages[currentPage]} />
</div>
)
}
+247
View File
@@ -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;
}
+48
View File
@@ -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 (
<html
lang="en"
className={`${inter.variable} ${atma.variable} ${spaceGrotesk.variable} ${instrumentSerif.variable}`}
>
<body className="font-sans antialiased">
{children}
<Analytics />
<Toaster />
</body>
</html>
)
}
+228
View File
@@ -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<File[]>([])
// 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 (
<div className="min-h-screen bg-background flex flex-col overflow-hidden relative">
{/* Background gradient blurs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo/10 rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-900/10 rounded-full blur-[120px]" />
</div>
<Navbar />
<main className="flex-1 flex flex-col lg:flex-row min-h-[calc(100vh-4rem)]">
{/* Left: Controls & Input */}
<div className="w-full lg:w-1/2 flex flex-col justify-center px-4 sm:px-6 lg:px-12 xl:px-20 py-4 sm:py-6 relative">
<div className="max-w-xl mx-auto lg:mx-0 w-full z-10">
<LandingHero />
<div className="space-y-4 sm:space-y-5 mt-4 sm:mt-5">
<div className="opacity-0 animate-fade-in-up animation-delay-100">
<StoryInput
prompt={prompt}
setPrompt={setPrompt}
style={style}
setStyle={setStyle}
characterFiles={characterFiles}
setCharacterFiles={setCharacterFiles}
/>
</div>
<div className="opacity-0 animate-fade-in-up animation-delay-200">
<CreateButton prompt={prompt} style={style} characterFiles={characterFiles} />
</div>
</div>
</div>
</div>
{/* Right: Visual Preview / Canvas */}
<div className="hidden lg:flex w-full lg:w-1/2 border-l border-border relative items-center justify-center overflow-hidden">
{/* Dot grid background */}
<div className="absolute inset-0 dot-grid opacity-30" />
<div className="relative z-10 flex flex-col gap-4">
{/* Background floating comics - less prominent */}
<div className="absolute -top-32 -left-20 opacity-20 animate-float animation-delay-300">
<div className="bg-white w-48 aspect-[3/4] p-2 shadow-2xl rounded-sm rotate-12">
<div className="w-full h-full bg-neutral-900 border-2 border-black overflow-hidden">
<img
src="/manga-style-hero-battle-scene.jpg"
alt="Background comic"
className="w-full h-full object-cover opacity-60"
/>
</div>
</div>
</div>
<div className="absolute -bottom-40 left-10 opacity-15 animate-float animation-delay-500">
<div className="bg-white w-56 aspect-[3/4] p-2 shadow-2xl rounded-sm -rotate-6">
<div className="w-full h-full bg-neutral-900 border-2 border-black overflow-hidden">
<img
src="/american-comic-superhero-flying.jpg"
alt="Background comic"
className="w-full h-full object-cover opacity-60"
/>
</div>
</div>
</div>
<div className="absolute top-20 -right-32 opacity-25 animate-float animation-delay-700">
<div className="bg-white w-52 aspect-[3/4] p-2 shadow-2xl rounded-sm -rotate-12">
<div className="w-full h-full bg-neutral-900 border-2 border-black overflow-hidden">
<img
src="/noir-detective-comic-panel-dark.jpg"
alt="Background comic"
className="w-full h-full object-cover opacity-60"
/>
</div>
</div>
</div>
<div className="absolute bottom-10 -right-24 opacity-18 animate-float animation-delay-1000">
<div className="bg-white w-44 aspect-[3/4] p-2 shadow-2xl rounded-sm rotate-6">
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative">
<img
src="/vintage-comic-book-cover-retro.jpg"
alt="Background comic"
className="w-full h-full object-cover opacity-60"
/>
</div>
</div>
</div>
<div className="relative">
<div className="bg-white w-80 aspect-[3/4] p-2 shadow-2xl rounded-sm hover:shadow-indigo/20 hover:shadow-3xl transition-all duration-300 hover:scale-[1.02]">
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative">
{/* Page transition container */}
<div className="relative w-full h-full">
{/* Page 1 */}
<div
className={`absolute inset-0 transition-all duration-500 ${
currentPage === 1 ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-full"
}`}
>
<img
src="/comic-book-page-with-superhero-action-scene-noir-s.jpg"
alt="Comic preview page 1"
className="w-full h-full object-cover opacity-80 grayscale-[20%] contrast-125"
/>
<div className="scan-line opacity-50" />
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-black/70 text-[9px] text-white font-mono uppercase tracking-widest border border-white/10">
Page 1
</div>
<div className="absolute bottom-8 left-4 right-8 bg-white text-black p-2 text-[10px] font-medium border-2 border-black shadow-[2px_2px_0px_rgba(0,0,0,1)] leading-tight transform -rotate-1">
{'"The city needs a hero..."'}
</div>
</div>
{/* Page 2 */}
<div
className={`absolute inset-0 transition-all duration-500 ${
currentPage === 2 ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full"
}`}
>
<img
src="/american-comic-superhero-flying.jpg"
alt="Comic preview page 2"
className="w-full h-full object-cover opacity-80 grayscale-[20%] contrast-125"
/>
<div className="scan-line opacity-50" />
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-black/70 text-[9px] text-white font-mono uppercase tracking-widest border border-white/10">
Page 2
</div>
<div className="absolute bottom-8 left-4 right-8 bg-white text-black p-2 text-[10px] font-medium border-2 border-black shadow-[2px_2px_0px_rgba(0,0,0,1)] leading-tight transform rotate-1">
{'"And a hero shall rise!"'}
</div>
</div>
<div
className={`absolute inset-0 transition-all duration-500 ${
currentPage === 3 ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full"
}`}
>
<img
src="/manga-style-hero-battle-scene.jpg"
alt="Comic preview page 3"
className="w-full h-full object-cover opacity-80 grayscale-[20%] contrast-125"
/>
<div className="scan-line opacity-50" />
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-black/70 text-[9px] text-white font-mono uppercase tracking-widest border border-white/10">
Page 3
</div>
<div className="absolute bottom-8 left-4 right-8 bg-white text-black p-2 text-[10px] font-medium border-2 border-black shadow-[2px_2px_0px_rgba(0,0,0,1)] leading-tight transform -rotate-1">
{'"The battle begins!"'}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute -right-20 top-1/2 -translate-y-1/2 flex flex-col gap-3">
<button
onClick={() => goToPage(1)}
className={`w-8 h-8 rounded-full glass-panel flex items-center justify-center shadow-lg cursor-pointer transition-all duration-200 w-8 ${
currentPage === 1 ? "border-indigo bg-indigo/10" : "hover:border-indigo/50 hover:bg-indigo/5"
}`}
aria-label="Go to page 1"
>
<div
className={`w-2 h-2 rounded-full transition-colors duration-300 ${currentPage === 1 ? "bg-indigo" : "bg-muted-foreground"}`}
/>
</button>
<button
onClick={() => goToPage(2)}
className={`w-8 h-8 rounded-full glass-panel flex items-center justify-center shadow-lg cursor-pointer transition-all duration-200 ${
currentPage === 2 ? "border-indigo bg-indigo/10" : "hover:border-indigo/50 hover:bg-indigo/5"
}`}
aria-label="Go to page 2"
>
<div
className={`w-2 h-2 rounded-full transition-colors duration-300 ${currentPage === 2 ? "bg-indigo" : "bg-muted-foreground"}`}
/>
</button>
<button
onClick={() => goToPage(3)}
className={`w-8 h-8 rounded-full glass-panel flex items-center justify-center shadow-lg cursor-pointer transition-all duration-200 ${
currentPage === 3 ? "border-indigo bg-indigo/10" : "hover:border-indigo/50 hover:bg-indigo/5"
}`}
aria-label="Go to page 3"
>
<div
className={`w-2 h-2 rounded-full transition-colors duration-300 ${currentPage === 3 ? "bg-indigo" : "bg-muted-foreground"}`}
/>
</button>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}