Add loading state and style constants, enhance API key modal with delete
This commit is contained in:
+18
-102
@@ -6,102 +6,21 @@ import {
|
|||||||
createStory,
|
createStory,
|
||||||
createPage,
|
createPage,
|
||||||
getNextPageNumber,
|
getNextPageNumber,
|
||||||
|
getStoryById,
|
||||||
} from "@/lib/db-actions";
|
} from "@/lib/db-actions";
|
||||||
import { freeTierRateLimit } from "@/lib/rate-limit";
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
|
|
||||||
const NEW_MODEL = false;
|
const NEW_MODEL = false;
|
||||||
|
|
||||||
const IMAGE_MODEL = NEW_MODEL
|
const IMAGE_MODEL = NEW_MODEL
|
||||||
? "google/gemini-3-pro-image"
|
? "google/gemini-3-pro-image"
|
||||||
: "google/flash-image-2.5";
|
: "google/flash-image-2.5";
|
||||||
|
|
||||||
const FIXED_DIMENSIONS = NEW_MODEL
|
const FIXED_DIMENSIONS = NEW_MODEL
|
||||||
? { width: 896, height: 1200 }
|
? { width: 896, height: 1200 }
|
||||||
: { width: 864, height: 1184 };
|
: { width: 864, height: 1184 };
|
||||||
|
|
||||||
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(
|
|
||||||
`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(`Character ${characterNumber} description:`, description);
|
|
||||||
return description;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error analyzing character ${characterNumber}:`, error);
|
|
||||||
return `Character ${characterNumber}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@@ -123,8 +42,6 @@ export async function POST(request: NextRequest) {
|
|||||||
previousContext = "",
|
previousContext = "",
|
||||||
} = await request.json();
|
} = await request.json();
|
||||||
|
|
||||||
console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId, hasApiKey: !!apiKey });
|
|
||||||
|
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing required fields" },
|
{ error: "Missing required fields" },
|
||||||
@@ -142,7 +59,9 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
const resetDate = new Date(reset);
|
const resetDate = new Date(reset);
|
||||||
const timeUntilReset = Math.ceil((reset - Date.now()) / (1000 * 60 * 60 * 24)); // days
|
const timeUntilReset = Math.ceil(
|
||||||
|
(reset - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
|
); // days
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -158,7 +77,9 @@ export async function POST(request: NextRequest) {
|
|||||||
finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT;
|
finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT;
|
||||||
if (!finalApiKey) {
|
if (!finalApiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Server configuration error - default API key not available" },
|
{
|
||||||
|
error: "Server configuration error - default API key not available",
|
||||||
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,39 +89,37 @@ export async function POST(request: NextRequest) {
|
|||||||
let story;
|
let story;
|
||||||
|
|
||||||
if (storyId) {
|
if (storyId) {
|
||||||
// Create page for existing story
|
const story = await getStoryById(storyId);
|
||||||
console.log("Creating page for existing story:", storyId);
|
if (!story) {
|
||||||
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const nextPageNumber = await getNextPageNumber(storyId);
|
const nextPageNumber = await getNextPageNumber(storyId);
|
||||||
page = await createPage({
|
page = await createPage({
|
||||||
storyId,
|
storyId,
|
||||||
pageNumber: nextPageNumber,
|
pageNumber: nextPageNumber,
|
||||||
prompt,
|
prompt,
|
||||||
characterImageUrls: characterImages,
|
characterImageUrls: characterImages,
|
||||||
style,
|
|
||||||
});
|
});
|
||||||
console.log("Page created:", page.id);
|
|
||||||
} else {
|
} else {
|
||||||
// Create new story and first page
|
|
||||||
console.log("Creating new story for user:", userId);
|
|
||||||
story = await createStory({
|
story = await createStory({
|
||||||
title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
style,
|
||||||
});
|
});
|
||||||
console.log("Story created:", story.id);
|
|
||||||
|
|
||||||
page = await createPage({
|
page = await createPage({
|
||||||
storyId: story.id,
|
storyId: story.id,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
prompt,
|
prompt,
|
||||||
characterImageUrls: characterImages,
|
characterImageUrls: characterImages,
|
||||||
style,
|
|
||||||
});
|
});
|
||||||
console.log("First page created:", page.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = FIXED_DIMENSIONS;
|
const dimensions = FIXED_DIMENSIONS;
|
||||||
const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir;
|
const styleInfo = COMIC_STYLES.find((s) => s.id === style);
|
||||||
|
const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt;
|
||||||
|
|
||||||
const continuationContext =
|
const continuationContext =
|
||||||
isContinuation && previousContext
|
isContinuation && previousContext
|
||||||
@@ -268,8 +187,6 @@ COMPOSITION:
|
|||||||
|
|
||||||
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
||||||
|
|
||||||
console.log("Generating comic with prompt length:", fullPrompt.length, "using tier:", isUsingFreeTier ? "free" : "paid");
|
|
||||||
|
|
||||||
const client = new Together({ apiKey: finalApiKey });
|
const client = new Together({ apiKey: finalApiKey });
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
@@ -329,7 +246,6 @@ COMPOSITION:
|
|||||||
// Update page in database
|
// Update page in database
|
||||||
try {
|
try {
|
||||||
await updatePage(page.id, imageUrl);
|
await updatePage(page.id, imageUrl);
|
||||||
console.log("Page updated with image:", page.id);
|
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error("Error updating page in database:", dbError);
|
console.error("Error updating page in database:", dbError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { storySlug: slug } = await params;
|
const { storySlug: slug } = await params;
|
||||||
console.log("API: Fetching story with slug:", slug, "for user:", userId);
|
|
||||||
|
|
||||||
// Special case: if slug is "all", return user's stories for debugging
|
// Special case: if slug is "all", return user's stories for debugging
|
||||||
if (slug === "all") {
|
if (slug === "all") {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function StoryEditorPage() {
|
|||||||
image: page.generatedImageUrl || "",
|
image: page.generatedImageUrl || "",
|
||||||
prompt: page.prompt,
|
prompt: page.prompt,
|
||||||
characterUploads: page.characterImageUrls,
|
characterUploads: page.characterImageUrls,
|
||||||
style: "noir",
|
style: storyData.style || "noir",
|
||||||
dbId: page.id,
|
dbId: page.id,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export default function StoryEditorPage() {
|
|||||||
console.error("Error loading story:", error)
|
console.error("Error loading story:", error)
|
||||||
toast({
|
toast({
|
||||||
title: "Error loading story",
|
title: "Error loading story",
|
||||||
description: "Failed to load the story data.",
|
description: "Failed to load story data.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
})
|
})
|
||||||
@@ -90,6 +90,20 @@ export default function StoryEditorPage() {
|
|||||||
}
|
}
|
||||||
}, [slug, toast])
|
}, [slug, toast])
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowRight") {
|
||||||
|
setCurrentPage((prev) => (prev < pages.length - 1 ? prev + 1 : prev))
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [pages.length])
|
||||||
|
|
||||||
const handleAddPage = () => {
|
const handleAddPage = () => {
|
||||||
const storedKey = localStorage.getItem("together_api_key")
|
const storedKey = localStorage.getItem("together_api_key")
|
||||||
if (!storedKey && pages.length >= 1) {
|
if (!storedKey && pages.length >= 1) {
|
||||||
|
|||||||
+9
-1
@@ -12,6 +12,7 @@ export default function Home() {
|
|||||||
const [prompt, setPrompt] = useState("")
|
const [prompt, setPrompt] = useState("")
|
||||||
const [style, setStyle] = useState("noir")
|
const [style, setStyle] = useState("noir")
|
||||||
const [characterFiles, setCharacterFiles] = useState<File[]>([])
|
const [characterFiles, setCharacterFiles] = useState<File[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
// Auto-loop through pages every 6 seconds
|
// Auto-loop through pages every 6 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,10 +52,17 @@ export default function Home() {
|
|||||||
setStyle={setStyle}
|
setStyle={setStyle}
|
||||||
characterFiles={characterFiles}
|
characterFiles={characterFiles}
|
||||||
setCharacterFiles={setCharacterFiles}
|
setCharacterFiles={setCharacterFiles}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="opacity-0 animate-fade-in-up animation-delay-200">
|
<div className="opacity-0 animate-fade-in-up animation-delay-200">
|
||||||
<CreateButton prompt={prompt} style={style} characterFiles={characterFiles} />
|
<CreateButton
|
||||||
|
prompt={prompt}
|
||||||
|
style={style}
|
||||||
|
characterFiles={characterFiles}
|
||||||
|
isLoading={isLoading}
|
||||||
|
setIsLoading={setIsLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Key, ExternalLink, ArrowRight } from "lucide-react";
|
import { Key, ExternalLink, ArrowRight, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +49,13 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
setApiKey("");
|
setApiKey("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
localStorage.removeItem("together_api_key");
|
||||||
|
setExistingKey(null);
|
||||||
|
setApiKey("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-md">
|
<DialogContent className="border border-border/50 rounded-lg bg-background max-w-md">
|
||||||
@@ -67,19 +74,30 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
|
|
||||||
<DialogDescription className="text-center text-muted-foreground">
|
<DialogDescription className="text-center text-muted-foreground">
|
||||||
{existingKey
|
{existingKey
|
||||||
? "Update your Together API key or add a new one."
|
? "Update your Together API key or add a new one. You can also delete your existing key."
|
||||||
: "Your first page was free! Add your Together API key to generate more pages."}
|
: "Your first page was free! Add your Together API key to generate more pages."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
<Input
|
<div className="relative">
|
||||||
type="password"
|
<Input
|
||||||
value={apiKey}
|
type="password"
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
value={apiKey}
|
||||||
placeholder="Enter your API key..."
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5"
|
placeholder={existingKey ? "Your current API key" : "Enter your API key..."}
|
||||||
/>
|
className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10"
|
||||||
|
/>
|
||||||
|
{apiKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiKey("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={TOGETHER_LINK}
|
href={TOGETHER_LINK}
|
||||||
@@ -95,10 +113,10 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onClose}
|
onClick={existingKey ? handleDelete : onClose}
|
||||||
className="flex-1 text-muted-foreground hover:text-white hover:bg-secondary"
|
className="flex-1 text-muted-foreground hover:text-white hover:bg-secondary"
|
||||||
>
|
>
|
||||||
Maybe Later
|
{existingKey ? "Delete API Key" : "Maybe Later"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react"
|
import { useState, useRef, useEffect, useMemo } from "react"
|
||||||
import { Upload, X, Loader2 } from "lucide-react"
|
import { Upload, X, Loader2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"
|
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants"
|
||||||
const COMIC_STYLES = [
|
|
||||||
{ id: "american-modern", name: "American Modern" },
|
|
||||||
{ id: "manga", name: "Manga" },
|
|
||||||
{ id: "noir", name: "Noir" },
|
|
||||||
{ id: "vintage", name: "Vintage" },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface GeneratePageModalProps {
|
interface GeneratePageModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -51,7 +45,11 @@ export function GeneratePageModal({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const selectedStyle = previousPageStyle || "noir"
|
const selectedStyleId = previousPageStyle || "noir"
|
||||||
|
const selectedStyle = useMemo(
|
||||||
|
() => COMIC_STYLES.find((s) => s.id === selectedStyleId)?.name || "Noir",
|
||||||
|
[selectedStyleId]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousCharacters && previousCharacters.length > 0) {
|
if (previousCharacters && previousCharacters.length > 0) {
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { FileText, ImageIcon, Palette } from "lucide-react"
|
import { FileText, ImageIcon, Palette } from "lucide-react";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
prompt: string
|
prompt: string;
|
||||||
characterUploads?: string[]
|
characterUploads?: string[];
|
||||||
style: string
|
style: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageInfoSheetProps {
|
interface PageInfoSheetProps {
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
page: PageData
|
page: PageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
||||||
|
const styleName = COMIC_STYLES.find((s) => s.id === page.style)?.name || page.style;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||||
<SheetContent className="w-full sm:max-w-md border-l border-border/50 bg-background px-6">
|
<SheetContent className="w-full sm:max-w-md border-l border-border/50 bg-background px-6">
|
||||||
<SheetHeader className="pb-4 border-b border-border/50 px-0">
|
<SheetHeader className="pb-4 border-b border-border/50 px-0">
|
||||||
<SheetTitle className="text-base font-medium text-white">Page {page.id} Details</SheetTitle>
|
<SheetTitle className="text-base font-medium text-white">
|
||||||
|
Page {page.id} Details
|
||||||
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className=" space-y-6">
|
<div className=" space-y-6">
|
||||||
@@ -34,7 +44,9 @@ export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
|||||||
<span>Prompt</span>
|
<span>Prompt</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 glass-panel rounded-lg">
|
<div className="p-3 glass-panel rounded-lg">
|
||||||
<p className="text-sm text-foreground leading-relaxed">{page.prompt}</p>
|
<p className="text-sm text-foreground leading-relaxed">
|
||||||
|
{page.prompt}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +57,7 @@ export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
|||||||
<span>Style</span>
|
<span>Style</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center px-3 py-1.5 glass-panel rounded-md">
|
<div className="inline-flex items-center px-3 py-1.5 glass-panel rounded-md">
|
||||||
<span className="text-sm text-foreground">{page.style}</span>
|
<span className="text-sm text-foreground">{styleName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +69,10 @@ export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
|||||||
{page.characterUploads && page.characterUploads.length > 0 ? (
|
{page.characterUploads && page.characterUploads.length > 0 ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{page.characterUploads.map((upload, index) => (
|
{page.characterUploads.map((upload, index) => (
|
||||||
<div key={index} className="relative h-24 w-24 rounded-lg overflow-hidden glass-panel">
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative h-24 w-24 rounded-lg overflow-hidden glass-panel"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={upload || "/placeholder.svg"}
|
src={upload || "/placeholder.svg"}
|
||||||
alt={`Uploaded character ${index + 1}`}
|
alt={`Uploaded character ${index + 1}`}
|
||||||
@@ -68,12 +83,14 @@ export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 glass-panel rounded-lg text-center">
|
<div className="p-4 glass-panel rounded-lg text-center">
|
||||||
<p className="text-sm text-muted-foreground">No characters uploaded</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No characters uploaded
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generated Image Preview */}
|
{/* Generated Image Preview
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<ImageIcon className="w-3.5 h-3.5" />
|
<ImageIcon className="w-3.5 h-3.5" />
|
||||||
@@ -86,9 +103,9 @@ export function PageInfoSheet({ isOpen, onClose, page }: PageInfoSheetProps) {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ interface CreateButtonProps {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
style: string;
|
style: string;
|
||||||
characterFiles: File[];
|
characterFiles: File[];
|
||||||
|
isLoading: boolean;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateButton({
|
export function CreateButton({
|
||||||
prompt,
|
prompt,
|
||||||
style,
|
style,
|
||||||
characterFiles,
|
characterFiles,
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
}: CreateButtonProps) {
|
}: CreateButtonProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [loadingStep, setLoadingStep] = useState(0);
|
const [loadingStep, setLoadingStep] = useState(0);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { uploadToS3 } = useS3Upload();
|
const { uploadToS3 } = useS3Upload();
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Github, Key, BookOpen, User, Plus } from "lucide-react";
|
import { Github, Key, BookOpen, User, Plus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ApiKeyModal } from "@/components/api-key-modal";
|
import { ApiKeyModal } from "@/components/api-key-modal";
|
||||||
import {
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
SignInButton,
|
|
||||||
SignUpButton,
|
|
||||||
SignedIn,
|
|
||||||
SignedOut,
|
|
||||||
UserButton,
|
|
||||||
} from "@clerk/nextjs";
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [showApiModal, setShowApiModal] = useState(false);
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
|
||||||
|
const { isLoaded } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const handleApiKeySubmit = (key: string) => {
|
const handleApiKeySubmit = (key: string) => {
|
||||||
@@ -24,10 +20,15 @@ export function Navbar() {
|
|||||||
|
|
||||||
const isOnStoriesPage = pathname === "/stories";
|
const isOnStoriesPage = pathname === "/stories";
|
||||||
|
|
||||||
|
if (!isLoaded) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="w-full h-14 sm:h-16 border-b border-border/50 flex items-center justify-between px-4 sm:px-6 lg:px-8 z-50 bg-background/80 backdrop-blur-md">
|
<nav className="w-full h-14 sm:h-16 border-b border-border/50 flex items-center justify-between px-4 sm:px-6 lg:px-8 z-50 bg-background/80 backdrop-blur-md">
|
||||||
<Link href="/" className="flex items-center gap-1 hover:opacity-80 transition-opacity">
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
<div className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center">
|
<div className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src="/images/makecomics-logo.png"
|
src="/images/makecomics-logo.png"
|
||||||
|
|||||||
@@ -5,13 +5,7 @@ import { useState } from "react";
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { Upload, X, Check } from "lucide-react";
|
import { Upload, X, Check } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
const COMIC_STYLES = [
|
|
||||||
{ id: "american-modern", name: "American Modern" },
|
|
||||||
{ id: "manga", name: "Manga" },
|
|
||||||
{ id: "noir", name: "Noir" },
|
|
||||||
{ id: "vintage", name: "Vintage" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface StoryInputProps {
|
interface StoryInputProps {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -20,6 +14,7 @@ interface StoryInputProps {
|
|||||||
setStyle: (style: string) => void;
|
setStyle: (style: string) => void;
|
||||||
characterFiles: File[];
|
characterFiles: File[];
|
||||||
setCharacterFiles: (files: File[]) => void;
|
setCharacterFiles: (files: File[]) => void;
|
||||||
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryInput({
|
export function StoryInput({
|
||||||
@@ -29,12 +24,19 @@ export function StoryInput({
|
|||||||
setStyle,
|
setStyle,
|
||||||
characterFiles,
|
characterFiles,
|
||||||
setCharacterFiles,
|
setCharacterFiles,
|
||||||
|
isLoading,
|
||||||
}: StoryInputProps) {
|
}: StoryInputProps) {
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
const [showPreview, setShowPreview] = useState<number | null>(null);
|
const [showPreview, setShowPreview] = useState<number | null>(null);
|
||||||
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
setShowStyleDropdown(false);
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
const handleFiles = (newFiles: FileList | null) => {
|
const handleFiles = (newFiles: FileList | null) => {
|
||||||
if (!newFiles) return;
|
if (!newFiles) return;
|
||||||
|
|
||||||
@@ -96,7 +98,8 @@ export function StoryInput({
|
|||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
placeholder="A cyberpunk detective standing in neon rain, holding a glowing datapad, moody lighting, noir style..."
|
||||||
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-16 leading-relaxed"
|
disabled={isLoading}
|
||||||
|
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-16 leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border/30 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-2">
|
<div className="mt-3 pt-3 border-t border-border/30 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-2">
|
||||||
@@ -118,9 +121,10 @@ export function StoryInput({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeFile(index);
|
if (!isLoading) removeFile(index);
|
||||||
}}
|
}}
|
||||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity"
|
disabled={isLoading}
|
||||||
|
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<X className="w-2.5 h-2.5 text-white" />
|
<X className="w-2.5 h-2.5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -128,8 +132,9 @@ export function StoryInput({
|
|||||||
))}
|
))}
|
||||||
{characterFiles.length < 2 && (
|
{characterFiles.length < 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => !isLoading && fileInputRef.current?.click()}
|
||||||
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors"
|
disabled={isLoading}
|
||||||
|
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border/50 disabled:hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -137,8 +142,9 @@ export function StoryInput({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => !isLoading && fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors"
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
<span>Upload Characters</span>
|
<span>Upload Characters</span>
|
||||||
@@ -153,9 +159,10 @@ export function StoryInput({
|
|||||||
<div className="relative dropdown-container z-60">
|
<div className="relative dropdown-container z-60">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowStyleDropdown(!showStyleDropdown);
|
if (!isLoading) setShowStyleDropdown(!showStyleDropdown);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md glass-panel glass-panel-hover transition-all text-xs text-muted-foreground hover:text-white"
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md glass-panel glass-panel-hover transition-all text-xs text-muted-foreground hover:text-white disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3 h-3"
|
className="w-3 h-3"
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants"
|
||||||
|
|
||||||
const COMIC_STYLES = [
|
interface StyleSelectorProps {
|
||||||
{ id: "american-modern", name: "American Modern" },
|
style: string
|
||||||
{ id: "manga", name: "Manga" },
|
setStyle: (style: string) => void
|
||||||
{ id: "noir", name: "Noir" },
|
}
|
||||||
{ id: "vintage", name: "Vintage" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function StyleSelector() {
|
export function StyleSelector({ style, setStyle }: StyleSelectorProps) {
|
||||||
const [selectedStyle, setSelectedStyle] = useState("noir")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -20,28 +17,30 @@ export function StyleSelector() {
|
|||||||
|
|
||||||
{/* Grid for style selection */}
|
{/* Grid for style selection */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{COMIC_STYLES.map((style) => (
|
{COMIC_STYLES.map((styleOption) => (
|
||||||
<button
|
<button
|
||||||
key={style.id}
|
key={styleOption.id}
|
||||||
onClick={() => setSelectedStyle(style.id)}
|
onClick={() => setStyle(styleOption.id)}
|
||||||
className={`
|
className={`
|
||||||
relative text-left transition-all duration-200 rounded-lg p-3.5 border-2 group
|
relative text-left transition-all duration-200 rounded-lg p-3.5 border-2 group
|
||||||
hover:scale-[1.02] active:scale-[0.98]
|
hover:scale-[1.02] active:scale-[0.98]
|
||||||
${
|
${
|
||||||
selectedStyle === style.id
|
style === styleOption.id
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
? "border-indigo bg-indigo shadow-md"
|
||||||
: "border-border hover:border-primary/30 bg-card hover:bg-muted/50"
|
: "border-border hover:border-indigo/50 bg-card hover:bg-muted/20"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-sm text-foreground font-display">{style.name}</h3>
|
<h3 className={`font-semibold text-sm font-display ${
|
||||||
|
style === styleOption.id ? "text-white" : "text-foreground"
|
||||||
|
}`}>{styleOption.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedStyle === style.id && (
|
{style === styleOption.id && (
|
||||||
<div className="bg-primary text-primary-foreground p-1 rounded-full shrink-0">
|
<div className="bg-white text-indigo p-1 rounded-full shrink-0">
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "stories" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "stories" ADD COLUMN "style" text DEFAULT 'noir' NOT NULL;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"id": "e7151eda-1d1b-4ade-b25c-7b9e719f7b87",
|
||||||
|
"prevId": "d4fd5445-071f-469e-9bc8-ccb05ead5ef9",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"name": "style",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'noir'"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1766674926877,
|
"when": 1766674926877,
|
||||||
"tag": "0003_harsh_molecule_man",
|
"tag": "0003_harsh_molecule_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766745845651,
|
||||||
|
"tag": "0004_real_maggott",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const COMIC_STYLES = [
|
||||||
|
{
|
||||||
|
id: "american-modern",
|
||||||
|
name: "American Modern",
|
||||||
|
prompt: "contemporary American superhero comic style, bold vibrant colors, dynamic heroic poses, detailed muscular anatomy, cinematic action scenes, modern digital art",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manga",
|
||||||
|
name: "Manga",
|
||||||
|
prompt: "Japanese manga style, clean precise black linework, screen tone shading, expressive eyes, dynamic speed lines, black and white with impact effects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "noir",
|
||||||
|
name: "Noir",
|
||||||
|
prompt: "film noir style, high contrast black and white, deep dramatic shadows, 1940s detective aesthetic, heavy bold inking, moody atmospheric lighting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vintage",
|
||||||
|
name: "Vintage",
|
||||||
|
prompt: "Golden Age 1950s comic style, visible halftone Ben-Day dots, limited retro color palette, nostalgic warm tones, classic adventure comics",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
+6
-8
@@ -3,7 +3,7 @@ import { stories, pages, type Story, type Page } from './schema';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { generateComicSlug } from './slug-generator';
|
import { generateComicSlug } from './slug-generator';
|
||||||
|
|
||||||
export async function createStory(data: { title: string; description?: string; userId: string }): Promise<Story> {
|
export async function createStory(data: { title: string; description?: string; userId: string; style?: string }): Promise<Story> {
|
||||||
// Generate a unique slug
|
// Generate a unique slug
|
||||||
let slug = generateComicSlug();
|
let slug = generateComicSlug();
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@@ -31,7 +31,6 @@ export async function createPage(data: {
|
|||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
characterImageUrls: string[];
|
characterImageUrls: string[];
|
||||||
style: string;
|
|
||||||
}): Promise<Page> {
|
}): Promise<Page> {
|
||||||
const [page] = await db.insert(pages).values(data).returning();
|
const [page] = await db.insert(pages).values(data).returning();
|
||||||
return page;
|
return page;
|
||||||
@@ -60,23 +59,22 @@ export async function getStoryWithPages(storyId: string): Promise<{ story: Story
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getStoryById(storyId: string): Promise<Story | null> {
|
||||||
|
const result = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1);
|
||||||
|
return result.length > 0 ? result[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStoryWithPagesBySlug(slug: string): Promise<{ story: Story; pages: Page[] } | null> {
|
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);
|
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) {
|
if (storyResult.length === 0) {
|
||||||
console.log("DB: No story found with slug:", slug);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("DB: Found story:", storyResult[0].id, storyResult[0].slug);
|
|
||||||
const storyPages = await db.select().from(pages)
|
const storyPages = await db.select().from(pages)
|
||||||
.where(eq(pages.storyId, storyResult[0].id))
|
.where(eq(pages.storyId, storyResult[0].id))
|
||||||
.orderBy(pages.pageNumber);
|
.orderBy(pages.pageNumber);
|
||||||
|
|
||||||
console.log("DB: Found pages count:", storyPages.length);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
story: storyResult[0],
|
story: storyResult[0],
|
||||||
pages: storyPages,
|
pages: storyPages,
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@ export const stories = pgTable('stories', {
|
|||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
userId: text('user_id').notNull(), // Required Clerk user ID
|
style: text('style').default('noir').notNull(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user