Add AI-generated title and description for new comic stories
This commit is contained in:
@@ -3,6 +3,7 @@ import Together from "together-ai";
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
import {
|
import {
|
||||||
updatePage,
|
updatePage,
|
||||||
|
updateStory,
|
||||||
createStory,
|
createStory,
|
||||||
createPage,
|
createPage,
|
||||||
getNextPageNumber,
|
getNextPageNumber,
|
||||||
@@ -25,6 +26,8 @@ const FIXED_DIMENSIONS = NEW_MODEL
|
|||||||
? { width: 896, height: 1200 }
|
? { width: 896, height: 1200 }
|
||||||
: { width: 864, height: 1184 };
|
: { width: 864, height: 1184 };
|
||||||
|
|
||||||
|
const TEXT_MODEL = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@@ -121,6 +124,7 @@ export async function POST(request: NextRequest) {
|
|||||||
referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2
|
referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2
|
||||||
} else {
|
} else {
|
||||||
// New story: no previous page reference
|
// New story: no previous page reference
|
||||||
|
// Create story with temporary title, will update with generated title
|
||||||
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,
|
||||||
@@ -151,6 +155,85 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const client = new Together({ apiKey: finalApiKey });
|
const client = new Together({ apiKey: finalApiKey });
|
||||||
|
|
||||||
|
// Generate title and description in parallel with image generation (only for new stories)
|
||||||
|
let titleGenerationPromise: Promise<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}> | null = null;
|
||||||
|
if (!storyId) {
|
||||||
|
titleGenerationPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const titlePrompt = `Based on this comic book prompt, generate a compelling title and description for the comic book.
|
||||||
|
|
||||||
|
Prompt: "${prompt}"
|
||||||
|
Style: ${COMIC_STYLES.find((s) => s.id === style)?.name || style}
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
1. A catchy, engaging title (maximum 60 characters)
|
||||||
|
2. A brief description (2-3 sentences, maximum 200 characters)
|
||||||
|
|
||||||
|
Format your response as JSON:
|
||||||
|
{
|
||||||
|
"title": "Title here",
|
||||||
|
"description": "Description here"
|
||||||
|
}
|
||||||
|
|
||||||
|
Only return the JSON, no other text.`;
|
||||||
|
|
||||||
|
const textResponse = await client.chat.completions.create({
|
||||||
|
model: TEXT_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"You are a creative assistant that generates compelling comic book titles and descriptions. Always respond with valid JSON only.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: titlePrompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = textResponse.choices[0]?.message?.content?.trim();
|
||||||
|
if (!content) {
|
||||||
|
throw new Error("No response from text generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSON from response (in case there's extra text)
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error("No JSON found in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
const rawTitle = parsed.title?.trim() || (prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt);
|
||||||
|
const rawDescription = parsed.description?.trim();
|
||||||
|
|
||||||
|
// Enforce character limits
|
||||||
|
const title = rawTitle.length > 60 ? rawTitle.substring(0, 57) + "..." : rawTitle;
|
||||||
|
const description = rawDescription && rawDescription.length > 200
|
||||||
|
? rawDescription.substring(0, 197) + "..."
|
||||||
|
: rawDescription;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating title and description:", error);
|
||||||
|
// Fallback to prompt-based title
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await client.images.generate({
|
response = await client.images.generate({
|
||||||
@@ -159,7 +242,8 @@ export async function POST(request: NextRequest) {
|
|||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
temperature: 0.1, // Lower temperature for more consistent face matching
|
temperature: 0.1, // Lower temperature for more consistent face matching
|
||||||
reference_images: referenceImages.length > 0 ? referenceImages : undefined,
|
reference_images:
|
||||||
|
referenceImages.length > 0 ? referenceImages : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Together AI API error:", error);
|
console.error("Together AI API error:", error);
|
||||||
@@ -205,9 +289,33 @@ export async function POST(request: NextRequest) {
|
|||||||
const imageUrl = response.data[0].url;
|
const imageUrl = response.data[0].url;
|
||||||
|
|
||||||
// Upload image to S3 for permanent storage
|
// Upload image to S3 for permanent storage
|
||||||
const s3Key = `${storyId || story!.id}/page-${page.pageNumber}-${Date.now()}.jpg`;
|
const s3Key = `${storyId || story!.id}/page-${
|
||||||
|
page.pageNumber
|
||||||
|
}-${Date.now()}.jpg`;
|
||||||
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
||||||
|
|
||||||
|
// Wait for title/description generation if it's a new story
|
||||||
|
let generatedTitle: string | undefined;
|
||||||
|
let generatedDescription: string | undefined;
|
||||||
|
if (titleGenerationPromise) {
|
||||||
|
const titleData = await titleGenerationPromise;
|
||||||
|
generatedTitle = titleData.title;
|
||||||
|
generatedDescription = titleData.description;
|
||||||
|
|
||||||
|
// Update story with generated title and description
|
||||||
|
try {
|
||||||
|
await updateStory(story!.id, {
|
||||||
|
title: generatedTitle,
|
||||||
|
description: generatedDescription,
|
||||||
|
});
|
||||||
|
// Update story object for response
|
||||||
|
story = { ...story, title: generatedTitle, description: generatedDescription };
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error("Error updating story title/description:", dbError);
|
||||||
|
// Continue even if update fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update page in database with S3 URL
|
// Update page in database with S3 URL
|
||||||
try {
|
try {
|
||||||
await updatePage(page.id, s3ImageUrl);
|
await updatePage(page.id, s3ImageUrl);
|
||||||
@@ -227,6 +335,8 @@ export async function POST(request: NextRequest) {
|
|||||||
storySlug: story!.slug,
|
storySlug: story!.slug,
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
pageNumber: page.pageNumber,
|
pageNumber: page.pageNumber,
|
||||||
|
title: generatedTitle || story!.title,
|
||||||
|
description: generatedDescription || story!.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(responseData);
|
return NextResponse.json(responseData);
|
||||||
|
|||||||
+41
-329
@@ -1,336 +1,48 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
|
import { getStoryWithPagesBySlug } from "@/lib/db-actions";
|
||||||
|
import { StoryEditorClient } from "./story-editor-client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
export async function generateMetadata({
|
||||||
import { useParams } from "next/navigation";
|
params,
|
||||||
import { useToast } from "@/hooks/use-toast";
|
}: {
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
params: Promise<{ storySlug: string }>;
|
||||||
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
}): Promise<Metadata> {
|
||||||
import { PageSidebar } from "@/components/editor/page-sidebar";
|
const { storySlug: slug } = await params;
|
||||||
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";
|
|
||||||
import { StoryLoader } from "@/components/ui/story-loader";
|
|
||||||
|
|
||||||
interface PageData {
|
try {
|
||||||
id: number; // pageNumber for component compatibility
|
const result = await getStoryWithPagesBySlug(slug);
|
||||||
title: string;
|
|
||||||
image: string;
|
|
||||||
prompt: string;
|
|
||||||
characterUploads?: string[];
|
|
||||||
style: string;
|
|
||||||
dbId?: string; // actual database UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoryData {
|
if (!result) {
|
||||||
id: string;
|
return {
|
||||||
slug: string;
|
title: "Story Not Found | MakeComics",
|
||||||
title: string;
|
description: "The requested comic story could not be found.",
|
||||||
description?: string | null;
|
};
|
||||||
style: string;
|
}
|
||||||
userId?: string | null;
|
|
||||||
isOwner?: boolean;
|
const { story } = result;
|
||||||
|
const title = `${story.title} | MakeComics`;
|
||||||
|
const description =
|
||||||
|
story.description ||
|
||||||
|
`${story.title} - Create your own comic book with MakeComics`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating metadata:", error);
|
||||||
|
return {
|
||||||
|
title: "MakeComics",
|
||||||
|
description: "Create your own comic book with MakeComics",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoryEditorPage() {
|
export default function StoryEditorPage() {
|
||||||
const params = useParams();
|
return <StoryEditorClient />;
|
||||||
const slug = params.storySlug as string;
|
|
||||||
|
|
||||||
const [story, setStory] = useState<StoryData | null>(null);
|
|
||||||
const [isOwner, setIsOwner] = useState<boolean>(false);
|
|
||||||
const [pages, setPages] = useState<PageData[]>([]);
|
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
|
||||||
const [showApiModal, setShowApiModal] = useState(false);
|
|
||||||
const [showInfoSheet, setShowInfoSheet] = useState(false);
|
|
||||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
|
||||||
const [loadingPageId, setLoadingPageId] = useState<number | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [existingCharacterImages, setExistingCharacterImages] = useState<
|
|
||||||
string[]
|
|
||||||
>([]);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [apiKey, setApiKey] = useApiKey();
|
|
||||||
|
|
||||||
// Load story and pages from API
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStoryData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/stories/${slug}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Story not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Editor: full API response:", result);
|
|
||||||
|
|
||||||
const {
|
|
||||||
story: storyData,
|
|
||||||
pages: pagesData,
|
|
||||||
isOwner: ownerStatus,
|
|
||||||
} = result;
|
|
||||||
|
|
||||||
console.log("Editor: received story data:", storyData);
|
|
||||||
|
|
||||||
setStory(storyData);
|
|
||||||
setIsOwner(ownerStatus ?? false); // Default to false if undefined
|
|
||||||
setPages(
|
|
||||||
pagesData.map((page: any) => ({
|
|
||||||
id: page.pageNumber,
|
|
||||||
title: storyData.title,
|
|
||||||
image: page.generatedImageUrl || "",
|
|
||||||
prompt: page.prompt,
|
|
||||||
characterUploads: page.characterImageUrls,
|
|
||||||
style: storyData.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 story data.",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (slug) {
|
|
||||||
loadStoryData();
|
|
||||||
}
|
|
||||||
}, [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 = () => {
|
|
||||||
if (!apiKey && pages.length >= 1) {
|
|
||||||
setShowApiModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowGenerateModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRedrawPage = async () => {
|
|
||||||
if (!apiKey) {
|
|
||||||
setShowApiModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPageData = pages[currentPage];
|
|
||||||
if (!currentPageData) return;
|
|
||||||
|
|
||||||
setLoadingPageId(currentPage);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/add-page", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
storyId: story?.slug,
|
|
||||||
pageId: currentPageData.dbId, // Add pageId to override existing page
|
|
||||||
prompt: currentPageData.prompt,
|
|
||||||
characterImages: currentPageData.characterUploads || [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || "Failed to redraw page");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
// Update the current page with the new image
|
|
||||||
setPages((prevPages) =>
|
|
||||||
prevPages.map((page, index) =>
|
|
||||||
index === currentPage ? { ...page, image: result.imageUrl } : page
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Page redrawn successfully",
|
|
||||||
description: "The page has been regenerated with a fresh image.",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error redrawing page:", error);
|
|
||||||
toast({
|
|
||||||
title: "Failed to redraw page",
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "Failed to redraw page",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingPageId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApiKeyClick = () => {
|
|
||||||
setShowApiModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApiKeySubmit = (key: string) => {
|
|
||||||
setApiKey(key);
|
|
||||||
setShowApiModal(false);
|
|
||||||
const wasGenerating = showGenerateModal;
|
|
||||||
if (wasGenerating) {
|
|
||||||
setShowGenerateModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGeneratePage = async (data: {
|
|
||||||
prompt: string;
|
|
||||||
characterFiles?: File[];
|
|
||||||
characterUrls?: string[];
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
if (!apiKey) {
|
|
||||||
setShowApiModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new page mode
|
|
||||||
const response = await fetch("/api/add-page", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
storyId: story?.slug,
|
|
||||||
prompt: data.prompt,
|
|
||||||
characterImages: data.characterUrls || [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || "Failed to generate page");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
setPages((prevPages) => [
|
|
||||||
...prevPages,
|
|
||||||
{
|
|
||||||
id: pages.length + 1,
|
|
||||||
title: story?.title || "",
|
|
||||||
image: result.imageUrl,
|
|
||||||
prompt: data.prompt,
|
|
||||||
characterUploads: data.characterUrls || [],
|
|
||||||
style: story?.style || "noir",
|
|
||||||
dbId: result.pageId,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setCurrentPage(pages.length);
|
|
||||||
|
|
||||||
setShowGenerateModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating page:", error);
|
|
||||||
toast({
|
|
||||||
title: "Failed to generate page",
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : "Failed to generate page",
|
|
||||||
variant: "destructive",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
|
||||||
<StoryLoader />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!story) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
|
||||||
<div className="text-white">Story not found</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col bg-background">
|
|
||||||
<EditorToolbar
|
|
||||||
title={story.title}
|
|
||||||
onContinueStory={handleAddPage}
|
|
||||||
isOwner={isOwner}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
|
||||||
<PageSidebar
|
|
||||||
pages={pages}
|
|
||||||
currentPage={currentPage}
|
|
||||||
onPageSelect={setCurrentPage}
|
|
||||||
onAddPage={handleAddPage}
|
|
||||||
loadingPageId={loadingPageId}
|
|
||||||
onApiKeyClick={handleApiKeyClick}
|
|
||||||
isOwner={isOwner}
|
|
||||||
/>
|
|
||||||
<ComicCanvas
|
|
||||||
page={pages[currentPage]}
|
|
||||||
pageIndex={currentPage}
|
|
||||||
isLoading={loadingPageId === currentPage}
|
|
||||||
isOwner={isOwner}
|
|
||||||
onInfoClick={() => setShowInfoSheet(true)}
|
|
||||||
onRedrawClick={handleRedrawPage}
|
|
||||||
onNextPage={() =>
|
|
||||||
setCurrentPage((prev) =>
|
|
||||||
prev < pages.length - 1 ? prev + 1 : prev
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPrevPage={() =>
|
|
||||||
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ApiKeyModal
|
|
||||||
isOpen={showApiModal}
|
|
||||||
onClose={() => setShowApiModal(false)}
|
|
||||||
onSubmit={handleApiKeySubmit}
|
|
||||||
/>
|
|
||||||
<GeneratePageModal
|
|
||||||
isOpen={showGenerateModal}
|
|
||||||
onClose={() => setShowGenerateModal(false)}
|
|
||||||
onGenerate={handleGeneratePage}
|
|
||||||
pageNumber={pages.length + 1}
|
|
||||||
/>
|
|
||||||
<PageInfoSheet
|
|
||||||
isOpen={showInfoSheet}
|
|
||||||
onClose={() => setShowInfoSheet(false)}
|
|
||||||
page={pages[currentPage]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
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";
|
||||||
|
import { StoryLoader } from "@/components/ui/story-loader";
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
id: number; // pageNumber for component compatibility
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
prompt: string;
|
||||||
|
characterUploads?: string[];
|
||||||
|
style: string;
|
||||||
|
dbId?: string; // actual database UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoryData {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
style: string;
|
||||||
|
userId?: string | null;
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryEditorClient() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params.storySlug as string;
|
||||||
|
|
||||||
|
const [story, setStory] = useState<StoryData | null>(null);
|
||||||
|
const [isOwner, setIsOwner] = useState<boolean>(false);
|
||||||
|
const [pages, setPages] = useState<PageData[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
|
const [showInfoSheet, setShowInfoSheet] = useState(false);
|
||||||
|
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||||
|
const [loadingPageId, setLoadingPageId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [existingCharacterImages, setExistingCharacterImages] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [apiKey, setApiKey] = useApiKey();
|
||||||
|
|
||||||
|
// Load story and pages from API
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStoryData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stories/${slug}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Story not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Editor: full API response:", result);
|
||||||
|
|
||||||
|
const {
|
||||||
|
story: storyData,
|
||||||
|
pages: pagesData,
|
||||||
|
isOwner: ownerStatus,
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
console.log("Editor: received story data:", storyData);
|
||||||
|
|
||||||
|
setStory(storyData);
|
||||||
|
setIsOwner(ownerStatus ?? false); // Default to false if undefined
|
||||||
|
setPages(
|
||||||
|
pagesData.map((page: any) => ({
|
||||||
|
id: page.pageNumber,
|
||||||
|
title: storyData.title,
|
||||||
|
image: page.generatedImageUrl || "",
|
||||||
|
prompt: page.prompt,
|
||||||
|
characterUploads: page.characterImageUrls,
|
||||||
|
style: storyData.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 story data.",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
loadStoryData();
|
||||||
|
}
|
||||||
|
}, [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 = () => {
|
||||||
|
if (!apiKey && pages.length >= 1) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedrawPage = async () => {
|
||||||
|
if (!apiKey) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPageData = pages[currentPage];
|
||||||
|
if (!currentPageData) return;
|
||||||
|
|
||||||
|
setLoadingPageId(currentPage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/add-page", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
storyId: story?.slug,
|
||||||
|
pageId: currentPageData.dbId, // Add pageId to override existing page
|
||||||
|
prompt: currentPageData.prompt,
|
||||||
|
characterImages: currentPageData.characterUploads || [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to redraw page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Update the current page with the new image
|
||||||
|
setPages((prevPages) =>
|
||||||
|
prevPages.map((page, index) =>
|
||||||
|
index === currentPage ? { ...page, image: result.imageUrl } : page
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Page redrawn successfully",
|
||||||
|
description: "The page has been regenerated with a fresh image.",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error redrawing page:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to redraw page",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Failed to redraw page",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingPageId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApiKeyClick = () => {
|
||||||
|
setShowApiModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApiKeySubmit = (key: string) => {
|
||||||
|
setApiKey(key);
|
||||||
|
setShowApiModal(false);
|
||||||
|
const wasGenerating = showGenerateModal;
|
||||||
|
if (wasGenerating) {
|
||||||
|
setShowGenerateModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeneratePage = async (data: {
|
||||||
|
prompt: string;
|
||||||
|
characterFiles?: File[];
|
||||||
|
characterUrls?: string[];
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!apiKey) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new page mode
|
||||||
|
const response = await fetch("/api/add-page", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
storyId: story?.slug,
|
||||||
|
prompt: data.prompt,
|
||||||
|
characterImages: data.characterUrls || [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to generate page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setPages((prevPages) => [
|
||||||
|
...prevPages,
|
||||||
|
{
|
||||||
|
id: pages.length + 1,
|
||||||
|
title: story?.title || "",
|
||||||
|
image: result.imageUrl,
|
||||||
|
prompt: data.prompt,
|
||||||
|
characterUploads: data.characterUrls || [],
|
||||||
|
style: story?.style || "noir",
|
||||||
|
dbId: result.pageId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setCurrentPage(pages.length);
|
||||||
|
|
||||||
|
setShowGenerateModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating page:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to generate page",
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : "Failed to generate page",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
|
<StoryLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!story) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-white">Story not found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-background">
|
||||||
|
<EditorToolbar
|
||||||
|
title={story.title}
|
||||||
|
onContinueStory={handleAddPage}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<PageSidebar
|
||||||
|
pages={pages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageSelect={setCurrentPage}
|
||||||
|
onAddPage={handleAddPage}
|
||||||
|
loadingPageId={loadingPageId}
|
||||||
|
onApiKeyClick={handleApiKeyClick}
|
||||||
|
isOwner={isOwner}
|
||||||
|
/>
|
||||||
|
<ComicCanvas
|
||||||
|
page={pages[currentPage]}
|
||||||
|
pageIndex={currentPage}
|
||||||
|
isLoading={loadingPageId === currentPage}
|
||||||
|
isOwner={isOwner}
|
||||||
|
onInfoClick={() => setShowInfoSheet(true)}
|
||||||
|
onRedrawClick={handleRedrawPage}
|
||||||
|
onNextPage={() =>
|
||||||
|
setCurrentPage((prev) =>
|
||||||
|
prev < pages.length - 1 ? prev + 1 : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPrevPage={() =>
|
||||||
|
setCurrentPage((prev) => (prev > 0 ? prev - 1 : prev))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApiKeyModal
|
||||||
|
isOpen={showApiModal}
|
||||||
|
onClose={() => setShowApiModal(false)}
|
||||||
|
onSubmit={handleApiKeySubmit}
|
||||||
|
/>
|
||||||
|
<GeneratePageModal
|
||||||
|
isOpen={showGenerateModal}
|
||||||
|
onClose={() => setShowGenerateModal(false)}
|
||||||
|
onGenerate={handleGeneratePage}
|
||||||
|
pageNumber={pages.length + 1}
|
||||||
|
/>
|
||||||
|
<PageInfoSheet
|
||||||
|
isOpen={showInfoSheet}
|
||||||
|
onClose={() => setShowInfoSheet(false)}
|
||||||
|
page={pages[currentPage]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -42,6 +42,12 @@ export async function updatePage(pageId: string, generatedImageUrl: string): Pro
|
|||||||
.where(eq(pages.id, pageId));
|
.where(eq(pages.id, pageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateStory(storyId: string, data: { title?: string; description?: string }): Promise<void> {
|
||||||
|
await db.update(stories)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(stories.id, storyId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStoryWithPages(storyId: string): Promise<{ story: Story; pages: Page[] } | null> {
|
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);
|
const storyResult = await db.select().from(stories).where(eq(stories.id, storyId)).limit(1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user