This commit adds support for reusing existing character images from exis
This commit is contained in:
@@ -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<string, string> = {
|
||||
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(
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PageData[]>(DEMO_PAGES)
|
||||
export default function StoryEditorPage() {
|
||||
const params = useParams()
|
||||
const slug = params.storySlug as string
|
||||
|
||||
const [story, setStory] = useState<StoryData | null>(null)
|
||||
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 [lastCharacterFiles, setLastCharacterFiles] = useState<File[]>([])
|
||||
const [lastCharacterUploads, setLastCharacterUploads] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [existingCharacterImages, setExistingCharacterImages] = useState<string[]>([])
|
||||
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,
|
||||
},
|
||||
])
|
||||
|
||||
if (data.characterUploads && data.characterUploads.length > 0) {
|
||||
setLastCharacterUploads(data.characterUploads)
|
||||
const loadStoryData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stories/${slug}`)
|
||||
if (!response.ok) {
|
||||
throw new Error("Story not found")
|
||||
}
|
||||
|
||||
sessionStorage.removeItem("firstPageData")
|
||||
}
|
||||
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: "Comic generated successfully",
|
||||
description: "Your comic page is ready to view",
|
||||
title: "Error loading story",
|
||||
description: "Failed to load the story data.",
|
||||
variant: "destructive",
|
||||
duration: 4000,
|
||||
})
|
||||
}, [toast])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
// Handle new character uploads
|
||||
let characterUploads: string[] = data.characterUrls || []
|
||||
|
||||
let characterUploads: string[] = []
|
||||
if (data.characterFiles && data.characterFiles.length > 0) {
|
||||
characterUploads = await Promise.all(data.characterFiles.map((file) => fileToBase64(file)))
|
||||
setLastCharacterUploads(characterUploads)
|
||||
const newUploads = await Promise.all(
|
||||
data.characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
|
||||
)
|
||||
characterUploads = [...characterUploads, ...newUploads]
|
||||
}
|
||||
|
||||
const newPage: PageData = {
|
||||
id: newPageId,
|
||||
title: pages[0].title,
|
||||
// Add loading page to UI
|
||||
const nextPageNumber = pages.length + 1
|
||||
const pageData: PageData = {
|
||||
id: nextPageNumber,
|
||||
title: story.title,
|
||||
image: "",
|
||||
prompt: data.prompt,
|
||||
characterUploads: characterUploads.length > 0 ? characterUploads : undefined,
|
||||
characterUploads,
|
||||
style: data.style,
|
||||
}
|
||||
|
||||
setPages([...pages, newPage])
|
||||
setPages([...pages, pageData])
|
||||
setCurrentPage(pages.length)
|
||||
setLoadingPageId(newPageId)
|
||||
setLastCharacterFiles(data.characterFiles || [])
|
||||
setLoadingPageId(nextPageNumber)
|
||||
|
||||
try {
|
||||
// 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<string> => {
|
||||
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 (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-white">Loading story...</div>
|
||||
</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={pages[0]?.title || "Untitled Comic"}
|
||||
title={story.title}
|
||||
onContinueStory={handleContinueStory}
|
||||
onInfoClick={() => setShowInfoSheet(true)}
|
||||
/>
|
||||
@@ -230,9 +278,10 @@ 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}
|
||||
/>
|
||||
<PageInfoSheet isOpen={showInfoSheet} onClose={() => setShowInfoSheet(false)} page={pages[currentPage]} />
|
||||
</div>
|
||||
@@ -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<File[]>(previousCharacters || [])
|
||||
const [selectedExistingCharacters, setSelectedExistingCharacters] = useState<string[]>([])
|
||||
const [previews, setPreviews] = useState<string[]>([])
|
||||
const [showPreview, setShowPreview] = useState<number | null>(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,7 +164,47 @@ 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"
|
||||
/>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/30 flex items-center justify-between gap-2">
|
||||
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
||||
{/* Existing Characters */}
|
||||
{existingCharacterImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-[0.02em] font-medium">
|
||||
Reuse Characters from Story
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{existingCharacterImages.map((characterUrl, index) => {
|
||||
const isSelected = selectedExistingCharacters.includes(characterUrl)
|
||||
return (
|
||||
<button
|
||||
key={characterUrl}
|
||||
onClick={() => toggleExistingCharacter(characterUrl)}
|
||||
className={`relative w-8 h-8 rounded-md overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-indigo shadow-sm shadow-indigo/20"
|
||||
: "border-border/50 hover:border-indigo/50"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={characterUrl}
|
||||
alt={`Existing character ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-indigo/20 flex items-center justify-center">
|
||||
<div className="w-3 h-3 bg-indigo rounded-full flex items-center justify-center">
|
||||
<div className="w-1 h-1 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Character Uploads */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{uploadedFiles.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -162,7 +216,7 @@ export function GeneratePageModal({
|
||||
>
|
||||
<img
|
||||
src={preview || "/placeholder.svg"}
|
||||
alt={`Character ${index + 1}`}
|
||||
alt={`New character ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
@@ -192,12 +246,13 @@ export function GeneratePageModal({
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Upload Characters</span>
|
||||
<span>Upload New Characters</span>
|
||||
<span className="text-muted-foreground/50">(Max 2)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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!,
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { loadEnvConfig } from "@next/env";
|
||||
|
||||
const projectDir = process.cwd();
|
||||
loadEnvConfig(projectDir);
|
||||
@@ -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<Story> {
|
||||
// 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<Page> {
|
||||
const [page] = await db.insert(pages).values(data).returning();
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function updatePage(pageId: string, generatedImageUrl: string): Promise<void> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<string[]>().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;
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user