initial commit with Youssef work
This commit is contained in:
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user