From ff25be7c85895bd2f290e2942e71b779070422df Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Tue, 23 Dec 2025 13:43:35 +0100 Subject: [PATCH] This commit adds support for reusing existing character images from exis --- app/api/generate-comic/route.ts | 59 ++++++- app/api/stories/[storySlug]/route.ts | 48 ++++++ app/editor/{ => [storySlug]}/page.tsx | 199 ++++++++++++++-------- components/editor/generate-page-modal.tsx | 133 ++++++++++----- components/landing/create-button.tsx | 44 +---- drizzle.config.ts | 11 ++ drizzle/0001_windy_ezekiel.sql | 2 + drizzle/meta/0000_snapshot.json | 150 ++++++++++++++++ drizzle/meta/0001_snapshot.json | 164 ++++++++++++++++++ drizzle/meta/_journal.json | 20 +++ envConfig.ts | 4 + lib/db-actions.ts | 107 ++++++++++++ lib/db.ts | 10 ++ lib/schema.ts | 44 +++++ lib/slug-generator.ts | 46 +++++ package.json | 1 + pnpm-lock.yaml | 8 + 17 files changed, 895 insertions(+), 155 deletions(-) create mode 100644 app/api/stories/[storySlug]/route.ts rename app/editor/{ => [storySlug]}/page.tsx (51%) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0001_windy_ezekiel.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 envConfig.ts create mode 100644 lib/db-actions.ts create mode 100644 lib/db.ts create mode 100644 lib/schema.ts create mode 100644 lib/slug-generator.ts diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index 54e38c8..4e64068 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import Together from "together-ai"; +import { updatePage, createStory, createPage, getNextPageNumber } from "@/lib/db-actions"; const FIXED_DIMENSIONS = { width: 864, height: 1184 }; @@ -91,6 +92,7 @@ const STYLE_DESCRIPTIONS: Record = { export async function POST(request: NextRequest) { try { const { + storyId, prompt, apiKey, style = "noir", @@ -99,7 +101,7 @@ export async function POST(request: NextRequest) { previousContext = "", } = await request.json(); - console.log("Received character image URLs:", characterImages); + console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length }); if (!prompt || !apiKey) { return NextResponse.json( @@ -108,6 +110,41 @@ export async function POST(request: NextRequest) { ); } + let page; + let story; + + if (storyId) { + // Create next page for existing story + console.log("Creating page for existing story:", storyId); + const nextPageNumber = await getNextPageNumber(storyId); + page = await createPage({ + storyId, + pageNumber: nextPageNumber, + prompt, + characterImageUrls: characterImages, + style, + }); + console.log("Page created:", page.id); + } else { + // Create new story and first page + console.log("Creating new story"); + story = await createStory({ + title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt, + description: undefined, + userId: undefined, + }); + console.log("Story created:", story.id); + + page = await createPage({ + storyId: story.id, + pageNumber: 1, + prompt, + characterImageUrls: characterImages, + style, + }); + console.log("First page created:", page.id); + } + const dimensions = FIXED_DIMENSIONS; const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir; @@ -221,7 +258,25 @@ COMPOSITION: ); } - return NextResponse.json({ imageUrl: response.data[0].url }); + const imageUrl = response.data[0].url; + + // Update page in database + try { + await updatePage(page.id, imageUrl); + console.log("Page updated with image:", page.id); + } catch (dbError) { + console.error("Error updating page in database:", dbError); + return NextResponse.json( + { error: "Failed to save generated image" }, + { status: 500 } + ); + } + + const responseData = storyId + ? { imageUrl, pageId: page.id, pageNumber: page.pageNumber } + : { imageUrl, storyId: story!.id, storySlug: story!.slug, pageId: page.id, pageNumber: page.pageNumber }; + + return NextResponse.json(responseData); } catch (error) { console.error("Error in generate-comic API:", error); return NextResponse.json( diff --git a/app/api/stories/[storySlug]/route.ts b/app/api/stories/[storySlug]/route.ts new file mode 100644 index 0000000..4de88b2 --- /dev/null +++ b/app/api/stories/[storySlug]/route.ts @@ -0,0 +1,48 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getStoryWithPagesBySlug } from "@/lib/db-actions"; +import { db } from "@/lib/db"; +import { stories } from "@/lib/schema"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ storySlug: string }> } +) { + try { + const { storySlug: slug } = await params; + console.log("API: Fetching story with slug:", slug); + + // Special case: if slug is "all", return all stories for debugging + if (slug === "all") { + const allStories = await db.select().from(stories); + return NextResponse.json({ + message: "All stories", + stories: allStories.map(s => ({ id: s.id, slug: s.slug, title: s.title })) + }); + } + + if (!slug) { + return NextResponse.json( + { error: "Story slug is required" }, + { status: 400 } + ); + } + + const result = await getStoryWithPagesBySlug(slug); + console.log("API: Result found:", !!result); + + if (!result) { + return NextResponse.json( + { error: "Story not found" }, + { status: 404 } + ); + } + + return NextResponse.json(result); + } catch (error) { + console.error("Error fetching story:", error); + return NextResponse.json( + { error: "Failed to fetch story" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/editor/page.tsx b/app/editor/[storySlug]/page.tsx similarity index 51% rename from app/editor/page.tsx rename to app/editor/[storySlug]/page.tsx index fa17567..6684543 100644 --- a/app/editor/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect } from "react" +import { useParams } from "next/navigation" import { useToast } from "@/hooks/use-toast" import { EditorToolbar } from "@/components/editor/editor-toolbar" import { PageSidebar } from "@/components/editor/page-sidebar" @@ -9,64 +10,85 @@ import { ApiKeyModal } from "@/components/api-key-modal" import { PageInfoSheet } from "@/components/editor/page-info-sheet" import { GeneratePageModal } from "@/components/editor/generate-page-modal" +import { useS3Upload } from "next-s3-upload" + interface PageData { - id: number + id: number // pageNumber for component compatibility title: string image: string prompt: string characterUploads?: string[] style: string + dbId?: string // actual database UUID } -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", - }, -] +interface StoryData { + id: string + title: string + description?: string | null + userId?: string | null +} -export default function EditorPage() { - const [pages, setPages] = useState(DEMO_PAGES) +export default function StoryEditorPage() { + const params = useParams() + const slug = params.storySlug as string + + const [story, setStory] = useState(null) + const [pages, setPages] = useState([]) 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 [isLoading, setIsLoading] = useState(true) + const [existingCharacterImages, setExistingCharacterImages] = useState([]) + const { uploadToS3 } = useS3Upload() const { toast } = useToast() + // Load story and pages from API 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, - }, - ]) + const loadStoryData = async () => { + try { + const response = await fetch(`/api/stories/${slug}`) + if (!response.ok) { + throw new Error("Story not found") + } - if (data.characterUploads && data.characterUploads.length > 0) { - setLastCharacterUploads(data.characterUploads) + const result = await response.json() + const { story: storyData, pages: pagesData } = result + + setStory(storyData) + setPages(pagesData.map((page: any) => ({ + id: page.pageNumber, + title: storyData.title, + image: page.generatedImageUrl || "", + prompt: page.prompt, + characterUploads: page.characterImageUrls, + style: "noir", + dbId: page.id, + }))) + + // Load existing character images for reuse + const uniqueImages = [...new Set(pagesData.flatMap((page: any) => page.characterImageUrls || []))] + setExistingCharacterImages(uniqueImages as string[]) + + } catch (error) { + console.error("Error loading story:", error) + toast({ + title: "Error loading story", + description: "Failed to load the story data.", + variant: "destructive", + duration: 4000, + }) + } finally { + setIsLoading(false) } - - sessionStorage.removeItem("firstPageData") } - toast({ - title: "Comic generated successfully", - description: "Your comic page is ready to view", - duration: 4000, - }) - }, [toast]) + if (slug) { + loadStoryData() + } + }, [slug, toast]) const handleAddPage = () => { const storedKey = localStorage.getItem("together_api_key") @@ -109,33 +131,40 @@ export default function EditorPage() { prompt: string style: string characterFiles?: File[] + characterUrls?: string[] // For reusing existing characters isContinuation?: boolean }) => { + if (!story) return + 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 { + // Handle new character uploads + let characterUploads: string[] = data.characterUrls || [] + + if (data.characterFiles && data.characterFiles.length > 0) { + const newUploads = await Promise.all( + data.characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)) + ) + characterUploads = [...characterUploads, ...newUploads] + } + + // Add loading page to UI + const nextPageNumber = pages.length + 1 + const pageData: PageData = { + id: nextPageNumber, + title: story.title, + image: "", + prompt: data.prompt, + characterUploads, + style: data.style, + } + + setPages([...pages, pageData]) + setCurrentPage(pages.length) + setLoadingPageId(nextPageNumber) + + // Generate the comic image const apiKey = localStorage.getItem("together_api_key") if (!apiKey) { throw new Error("API key not found") @@ -149,65 +178,84 @@ export default function EditorPage() { "Content-Type": "application/json", }, body: JSON.stringify({ + storyId: story?.id, prompt: data.prompt, - apiKey: apiKey, + apiKey, style: data.style, + characterImages: characterUploads, isContinuation: data.isContinuation, previousContext: data.isContinuation ? previousPage?.prompt : undefined, }), }) if (!response.ok) { - throw new Error("Failed to generate image") + const errorData = await response.json() + throw new Error(errorData.error || "Failed to generate image") } const result = await response.json() + // Update page with generated image setPages((prevPages) => prevPages.map((page) => - page.id === newPageId + page.id === nextPageNumber ? { ...page, image: result.imageUrl, + dbId: result.pageId, } : page, ), ) + // Update existing character images for future reuse + if (characterUploads.length > 0) { + const updatedImages = [...new Set([...existingCharacterImages, ...characterUploads])] + setExistingCharacterImages(updatedImages) + } + toast({ title: "Page generated successfully", - description: `Page ${newPageId} is ready`, + description: `Page ${nextPageNumber} is ready`, duration: 4000, }) } catch (error) { console.error("Error generating page:", error) toast({ title: "Generation failed", - description: "Failed to generate comic page. Please try again.", + description: error instanceof Error ? error.message : "Failed to generate comic page. Please try again.", variant: "destructive", duration: 4000, }) - setPages((prevPages) => prevPages.filter((page) => page.id !== newPageId)) + // Remove failed page from state + setPages((prevPages) => prevPages.filter((page) => page.id !== loadingPageId)) 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) - }) + if (isLoading) { + return ( +
+
Loading story...
+
+ ) + } + + if (!story) { + return ( +
+
Story not found
+
+ ) } return (
setShowInfoSheet(true)} /> @@ -230,11 +278,12 @@ export default function EditorPage() { onClose={() => setShowGenerateModal(false)} onGenerate={handleGeneratePage} pageNumber={pages.length + 1} - previousCharacters={lastCharacterFiles} + previousCharacters={[]} // Will be updated with character reuse previousPagePrompt={pages[pages.length - 1]?.prompt} previousPageStyle={pages[pages.length - 1]?.style?.toLowerCase()} + existingCharacterImages={existingCharacterImages} /> setShowInfoSheet(false)} page={pages[currentPage]} />
) -} +} \ No newline at end of file diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx index f1086ff..b3e32b1 100644 --- a/components/editor/generate-page-modal.tsx +++ b/components/editor/generate-page-modal.tsx @@ -19,12 +19,14 @@ interface GeneratePageModalProps { prompt: string style: string characterFiles?: File[] + characterUrls?: string[] // For reusing existing characters isContinuation?: boolean }) => void pageNumber: number previousCharacters?: File[] previousPagePrompt?: string previousPageStyle?: string + existingCharacterImages?: string[] // Character images from previous pages } export function GeneratePageModal({ @@ -35,9 +37,11 @@ export function GeneratePageModal({ previousCharacters, previousPagePrompt, previousPageStyle, + existingCharacterImages = [], }: GeneratePageModalProps) { const [prompt, setPrompt] = useState("") const [uploadedFiles, setUploadedFiles] = useState(previousCharacters || []) + const [selectedExistingCharacters, setSelectedExistingCharacters] = useState([]) const [previews, setPreviews] = useState([]) const [showPreview, setShowPreview] = useState(null) const [isGenerating, setIsGenerating] = useState(false) @@ -94,6 +98,14 @@ export function GeneratePageModal({ } } + const toggleExistingCharacter = (characterUrl: string) => { + setSelectedExistingCharacters(prev => + prev.includes(characterUrl) + ? prev.filter(url => url !== characterUrl) + : [...prev, characterUrl] + ) + } + const handleGenerate = () => { if (!prompt.trim()) return setIsGenerating(true) @@ -101,6 +113,7 @@ export function GeneratePageModal({ prompt, style: selectedStyle, characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, + characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined, isContinuation: false, }) } @@ -111,6 +124,7 @@ export function GeneratePageModal({ prompt: prompt.trim() || `Continue the story from where it left off. Previous context: ${previousPagePrompt}`, style: selectedStyle, characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, + characterUrls: selectedExistingCharacters.length > 0 ? selectedExistingCharacters : undefined, isContinuation: true, }) } @@ -150,52 +164,93 @@ export function GeneratePageModal({ className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight" /> -
-
- {uploadedFiles.length > 0 ? ( -
- {previews.map((preview, index) => ( -
+
+ {/* Existing Characters */} + {existingCharacterImages.length > 0 && ( +
+
+ Reuse Characters from Story +
+
+ {existingCharacterImages.map((characterUrl, index) => { + const isSelected = selectedExistingCharacters.includes(characterUrl) + return ( - -
- ))} - {uploadedFiles.length < 2 && ( - - )} + ) + })}
- ) : ( - - )} +
+ )} + + {/* New Character Uploads */} +
+
+ {uploadedFiles.length > 0 ? ( +
+ {previews.map((preview, index) => ( +
+ + +
+ ))} + {uploadedFiles.length < 2 && ( + + )} +
+ ) : ( + + )} +
diff --git a/components/landing/create-button.tsx b/components/landing/create-button.tsx index 2c328c2..25c98ee 100644 --- a/components/landing/create-button.tsx +++ b/components/landing/create-button.tsx @@ -54,22 +54,9 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp try { const apiKey = localStorage.getItem("together_api_key") - const characterUploads = await Promise.all(characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))) - if (!apiKey) { - const comicData = { - prompt, - style, - characterUploads, - } - sessionStorage.setItem("firstPageData", JSON.stringify(comicData)) - setTimeout(() => { - router.push("/editor") - }, 7500) - return - } - + // Use API to create story and generate first page const response = await fetch("/api/generate-comic", { method: "POST", headers: { @@ -79,41 +66,20 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp prompt, apiKey, style, - characterImages: characterUploads, // Send S3 URLs to API + characterImages: characterUploads, }), }) if (!response.ok) { const errorData = await response.json() - - if (response.status === 402 || errorData.errorType === "credit_limit") { - toast({ - title: "API credits required", - description: "Your Together.ai API key needs credits. Please add credits or use a different API key.", - variant: "destructive", - duration: 6000, - }) - setIsLoading(false) - return - } - - throw new Error(errorData.error || "Failed to generate comic") + throw new Error(errorData.error || "Failed to create story") } const result = await response.json() - const comicData = { - prompt, - style, - imageUrl: result.imageUrl, - characterUploads, - } + // Redirect to the story editor using slug + router.push(`/editor/${result.storySlug}`) - sessionStorage.setItem("firstPageData", JSON.stringify(comicData)) - - setTimeout(() => { - router.push("/editor") - }, 1000) } catch (error) { console.error("Error creating comic:", error) toast({ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..e57b4a9 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "drizzle-kit"; +import "./envConfig.ts"; + +export default defineConfig({ + schema: "./lib/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/drizzle/0001_windy_ezekiel.sql b/drizzle/0001_windy_ezekiel.sql new file mode 100644 index 0000000..28e11a9 --- /dev/null +++ b/drizzle/0001_windy_ezekiel.sql @@ -0,0 +1,2 @@ +ALTER TABLE "stories" ADD COLUMN "slug" text NOT NULL;--> statement-breakpoint +ALTER TABLE "stories" ADD CONSTRAINT "stories_slug_unique" UNIQUE("slug"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..6037c64 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,150 @@ +{ + "id": "9f365f0f-758f-4f79-a4a0-aa60ba7bc2f0", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "story_id": { + "name": "story_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_image_urls": { + "name": "character_image_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "generated_image_url": { + "name": "generated_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pages_story_id_stories_id_fk": { + "name": "pages_story_id_stories_id_fk", + "tableFrom": "pages", + "tableTo": "stories", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stories": { + "name": "stories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..9d391a4 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,164 @@ +{ + "id": "5ab9181f-d5fe-4f00-8822-d2c4f5be3322", + "prevId": "9f365f0f-758f-4f79-a4a0-aa60ba7bc2f0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "story_id": { + "name": "story_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_image_urls": { + "name": "character_image_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "generated_image_url": { + "name": "generated_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pages_story_id_stories_id_fk": { + "name": "pages_story_id_stories_id_fk", + "tableFrom": "pages", + "tableTo": "stories", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stories": { + "name": "stories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stories_slug_unique": { + "name": "stories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..7008510 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1766491185401, + "tag": "0000_amusing_blacklash", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1766492848044, + "tag": "0001_windy_ezekiel", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/envConfig.ts b/envConfig.ts new file mode 100644 index 0000000..6198985 --- /dev/null +++ b/envConfig.ts @@ -0,0 +1,4 @@ +import { loadEnvConfig } from "@next/env"; + +const projectDir = process.cwd(); +loadEnvConfig(projectDir); diff --git a/lib/db-actions.ts b/lib/db-actions.ts new file mode 100644 index 0000000..6714e06 --- /dev/null +++ b/lib/db-actions.ts @@ -0,0 +1,107 @@ +import { db } from './db'; +import { stories, pages, type Story, type Page } from './schema'; +import { eq } from 'drizzle-orm'; +import { generateComicSlug } from './slug-generator'; + +export async function createStory(data: { title: string; description?: string; userId?: string }): Promise { + // Generate a unique slug + let slug = generateComicSlug(); + let attempts = 0; + const maxAttempts = 10; + + // Ensure slug uniqueness + while (attempts < maxAttempts) { + const existing = await db.select().from(stories).where(eq(stories.slug, slug)).limit(1); + if (existing.length === 0) break; + slug = generateComicSlug(); + attempts++; + } + + if (attempts >= maxAttempts) { + // Fallback to a simple random slug if we can't generate a unique one + slug = `story-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; + } + + const [story] = await db.insert(stories).values({ ...data, slug }).returning(); + return story; +} + +export async function createPage(data: { + storyId: string; + pageNumber: number; + prompt: string; + characterImageUrls: string[]; + style: string; +}): Promise { + const [page] = await db.insert(pages).values(data).returning(); + return page; +} + +export async function updatePage(pageId: string, generatedImageUrl: string): Promise { + await db.update(pages) + .set({ generatedImageUrl, updatedAt: new Date() }) + .where(eq(pages.id, pageId)); +} + +export async function getStoryWithPages(storyId: string): Promise<{ story: Story; pages: Page[] } | null> { + const storyResult = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1); + + if (storyResult.length === 0) { + return null; + } + + const storyPages = await db.select().from(pages) + .where(eq(pages.storyId, storyId)) + .orderBy(pages.pageNumber); + + return { + story: storyResult[0], + pages: storyPages, + }; +} + +export async function getStoryWithPagesBySlug(slug: string): Promise<{ story: Story; pages: Page[] } | null> { + console.log("DB: Searching for slug:", slug); + const storyResult = await db.select().from(stories).where(eq(stories.slug, slug)).limit(1); + console.log("DB: Story result count:", storyResult.length); + + if (storyResult.length === 0) { + console.log("DB: No story found with slug:", slug); + return null; + } + + console.log("DB: Found story:", storyResult[0].id, storyResult[0].slug); + const storyPages = await db.select().from(pages) + .where(eq(pages.storyId, storyResult[0].id)) + .orderBy(pages.pageNumber); + + console.log("DB: Found pages count:", storyPages.length); + + return { + story: storyResult[0], + pages: storyPages, + }; +} + +export async function getStoryCharacterImages(storyId: string): Promise { + const storyPages = await db.select({ characterImageUrls: pages.characterImageUrls }) + .from(pages) + .where(eq(pages.storyId, storyId)); + + // Flatten all character URLs from all pages and remove duplicates + const allUrls = storyPages.flatMap(page => page.characterImageUrls); + return [...new Set(allUrls)]; // Remove duplicates +} + +export async function getNextPageNumber(storyId: string): Promise { + const storyPages = await db.select({ pageNumber: pages.pageNumber }) + .from(pages) + .where(eq(pages.storyId, storyId)) + .orderBy(pages.pageNumber); + + if (storyPages.length === 0) { + return 1; + } + + return Math.max(...storyPages.map(p => p.pageNumber)) + 1; +} \ No newline at end of file diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..b5e5c2d --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,10 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-http"; +import "../envConfig.ts"; + +if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL environment variable is not set"); +} + +const sql = neon(process.env.DATABASE_URL); +export const db = drizzle(sql); diff --git a/lib/schema.ts b/lib/schema.ts new file mode 100644 index 0000000..6827d95 --- /dev/null +++ b/lib/schema.ts @@ -0,0 +1,44 @@ +import { pgTable, text, integer, timestamp, uuid, jsonb } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// Stories table +export const stories = pgTable('stories', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + slug: text('slug').notNull().unique(), + description: text('description'), + userId: uuid('user_id'), // Optional - for future Clerk auth + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Pages table +export const pages = pgTable('pages', { + id: uuid('id').primaryKey().defaultRandom(), + storyId: uuid('story_id').references(() => stories.id, { onDelete: 'cascade' }).notNull(), + pageNumber: integer('page_number').notNull(), + prompt: text('prompt').notNull(), + characterImageUrls: jsonb('character_image_urls').$type().default([]).notNull(), + generatedImageUrl: text('generated_image_url'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Relations +export const storiesRelations = relations(stories, ({ many }) => ({ + pages: many(pages), +})); + +export const pagesRelations = relations(pages, ({ one }) => ({ + story: one(stories, { + fields: [pages.storyId], + references: [stories.id], + }), +})); + +// Types +export type Story = typeof stories.$inferSelect; +export type NewStory = typeof stories.$inferInsert; + +export type Page = typeof pages.$inferSelect; +export type NewPage = typeof pages.$inferInsert; \ No newline at end of file diff --git a/lib/slug-generator.ts b/lib/slug-generator.ts new file mode 100644 index 0000000..fb9254e --- /dev/null +++ b/lib/slug-generator.ts @@ -0,0 +1,46 @@ +// Comic-themed words for generating beautiful slugs +const COMIC_WORDS = { + heroes: ['super', 'hero', 'captain', 'iron', 'spider', 'bat', 'wonder', 'flash', 'green', 'black', 'deadpool', 'wolverine', 'hulk', 'thor', 'captain'], + villains: ['dark', 'shadow', 'evil', 'master', 'doctor', 'joker', 'lex', 'magneto', 'thanos', 'loki', 'venom', 'bane', 'riddler'], + actions: ['strike', 'force', 'power', 'legend', 'saga', 'quest', 'battle', 'warrior', 'guardian', 'defender', 'avenger', 'justice'], + settings: ['city', 'world', 'universe', 'realm', 'dimension', 'galaxy', 'earth', 'mars', 'moon', 'space', 'future', 'past'], + styles: ['noir', 'manga', 'comic', 'graphic', 'epic', 'legend', 'myth', 'tale', 'story', 'chronicle', 'adventure'] +}; + +const NUMBERS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']; + +export function generateComicSlug(): string { + // Generate 2-3 random words from different categories + const categories = Object.keys(COMIC_WORDS) as (keyof typeof COMIC_WORDS)[]; + const selectedCategories = categories.sort(() => 0.5 - Math.random()).slice(0, 2 + Math.floor(Math.random() * 2)); + + const words: string[] = []; + selectedCategories.forEach(category => { + const categoryWords = COMIC_WORDS[category]; + const randomWord = categoryWords[Math.floor(Math.random() * categoryWords.length)]; + words.push(randomWord); + }); + + // Add a random number word sometimes + if (Math.random() > 0.7) { + const randomNumber = NUMBERS[Math.floor(Math.random() * NUMBERS.length)]; + words.push(randomNumber); + } + + // Generate short random string (4-5 chars) + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const randomString = Array.from({ length: 4 + Math.floor(Math.random() * 2) }, () => + chars[Math.floor(Math.random() * chars.length)] + ).join(''); + + // Combine words with hyphens and add random string + const slugWords = words.join('-'); + return `${slugWords}-${randomString}`; +} + +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} \ No newline at end of file diff --git a/package.json b/package.json index 8e2408d..d085a61 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@hookform/resolvers": "^3.10.0", "@neondatabase/serverless": "^1.0.2", + "@next/env": "^16.1.1", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3affae0..dc029bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@neondatabase/serverless': specifier: ^1.0.2 version: 1.0.2 + '@next/env': + specifier: ^16.1.1 + version: 16.1.1 '@radix-ui/react-accordion': specifier: 1.2.2 version: 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -875,6 +878,9 @@ packages: '@next/env@16.0.10': resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} + '@next/env@16.1.1': + resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} + '@next/swc-darwin-arm64@16.0.10': resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} engines: {node: '>= 10'} @@ -3561,6 +3567,8 @@ snapshots: '@next/env@16.0.10': {} + '@next/env@16.1.1': {} + '@next/swc-darwin-arm64@16.0.10': optional: true