From 3731e3f476e3ea5197ad958457a1b43e3861c135 Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Mon, 22 Dec 2025 20:20:39 +0100 Subject: [PATCH] initial commit with Youssef work --- .gitignore | 27 + README.md | 2 +- app/api/generate-comic/route.ts | 227 ++ app/editor/page.tsx | 240 ++ app/globals.css | 247 ++ app/layout.tsx | 48 + app/page.tsx | 228 ++ components.json | 21 + components/api-key-modal.tsx | 110 + components/editor/comic-canvas.tsx | 69 + components/editor/editor-toolbar.tsx | 68 + components/editor/generate-page-modal.tsx | 272 ++ components/editor/page-info-sheet.tsx | 94 + components/editor/page-sidebar.tsx | 88 + components/landing/character-uploader.tsx | 92 + components/landing/create-button.tsx | 159 + components/landing/footer.tsx | 48 + components/landing/hero-section.tsx | 25 + components/landing/navbar.tsx | 57 + components/landing/story-input.tsx | 218 ++ components/landing/style-selector.tsx | 54 + components/theme-provider.tsx | 11 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 213 ++ components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 353 ++ components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 ++ components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/empty.tsx | 104 + components/ui/field.tsx | 244 ++ components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 169 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 ++ components/ui/navigation-menu.tsx | 166 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 ++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 + hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 + lib/utils.ts | 6 + next.config.mjs | 11 + package.json | 73 + pnpm-lock.yaml | 3180 +++++++++++++++++ postcss.config.mjs | 8 + public/american-comic-superhero-flying.jpg | Bin 0 -> 234670 bytes public/apple-icon.png | Bin 0 -> 2626 bytes public/comic-book-next-scene-continuation.jpg | Bin 0 -> 245930 bytes ...age-with-superhero-action-scene-noir-s.jpg | Bin 0 -> 226686 bytes ...ooftop-confrontation-superhero-villain.jpg | Bin 0 -> 185757 bytes ...uperhero-action-scene-noir-style-dark-.jpg | Bin 0 -> 219464 bytes public/icon-dark-32x32.png | Bin 0 -> 585 bytes public/icon-light-32x32.png | Bin 0 -> 566 bytes public/icon.svg | 26 + public/images/makecomics-logo.png | Bin 0 -> 100893 bytes public/manga-style-hero-battle-scene.jpg | Bin 0 -> 312230 bytes public/noir-detective-comic-panel-dark.jpg | Bin 0 -> 167652 bytes public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + public/vintage-comic-book-cover-retro.jpg | Bin 0 -> 181313 bytes styles/globals.css | 125 + tsconfig.json | 41 + 106 files changed, 12531 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 app/api/generate-comic/route.ts create mode 100644 app/editor/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/api-key-modal.tsx create mode 100644 components/editor/comic-canvas.tsx create mode 100644 components/editor/editor-toolbar.tsx create mode 100644 components/editor/generate-page-modal.tsx create mode 100644 components/editor/page-info-sheet.tsx create mode 100644 components/editor/page-sidebar.tsx create mode 100644 components/landing/character-uploader.tsx create mode 100644 components/landing/create-button.tsx create mode 100644 components/landing/footer.tsx create mode 100644 components/landing/hero-section.tsx create mode 100644 components/landing/navbar.tsx create mode 100644 components/landing/story-input.tsx create mode 100644 components/landing/style-selector.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/utils.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/american-comic-superhero-flying.jpg create mode 100644 public/apple-icon.png create mode 100644 public/comic-book-next-scene-continuation.jpg create mode 100644 public/comic-book-page-with-superhero-action-scene-noir-s.jpg create mode 100644 public/comic-book-rooftop-confrontation-superhero-villain.jpg create mode 100644 public/comic-book-superhero-action-scene-noir-style-dark-.jpg create mode 100644 public/icon-dark-32x32.png create mode 100644 public/icon-light-32x32.png create mode 100644 public/icon.svg create mode 100644 public/images/makecomics-logo.png create mode 100644 public/manga-style-hero-battle-scene.jpg create mode 100644 public/noir-detective-comic-panel-dark.jpg create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100644 public/vintage-comic-book-cover-retro.jpg create mode 100644 styles/globals.css create mode 100644 tsconfig.json 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} +
+
+ +