Use frontend-selected characters instead of fetching recent ones

This commit is contained in:
Riccardo Giorato
2025-12-26 21:57:25 +01:00
parent ddf05e6259
commit a618754337
6 changed files with 278 additions and 93 deletions
+2 -6
View File
@@ -10,7 +10,6 @@ import {
getNextPageNumber,
getStoryWithPagesBySlug,
getLastPageImage,
getStoryCharacterImages,
} from "@/lib/db-actions";
import { freeTierRateLimit } from "@/lib/rate-limit";
import { uploadImageToS3 } from "@/lib/s3-upload";
@@ -134,11 +133,8 @@ export async function POST(request: NextRequest) {
}
}
// Get story character images (up to 2 most recent)
const storyCharacterImages = await getStoryCharacterImages(story.id);
referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2
// Add current character images to references
// Use only the character images sent from the frontend (user's selection)
// These are already the most recent/relevant characters the user wants to use
referenceImages.push(...characterImages);
// Build the prompt with continuation context
+3 -4
View File
@@ -119,9 +119,8 @@ export async function POST(request: NextRequest) {
}
}
// Get story character images (up to 2 most recent)
const storyCharacterImages = await getStoryCharacterImages(storyId);
referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2
// For continuation pages, character images are sent from frontend
// No need to fetch separately - frontend handles selection
} else {
// New story: no previous page reference
// Create story with temporary title, will update with generated title
@@ -140,7 +139,7 @@ export async function POST(request: NextRequest) {
});
}
// Add current character images to references
// Use only the character images sent from the frontend
referenceImages.push(...characterImages);
const dimensions = FIXED_DIMENSIONS;
+2
View File
@@ -21,6 +21,7 @@ export async function GET() {
id: stories.id,
title: stories.title,
slug: stories.slug,
style: stories.style,
createdAt: stories.createdAt,
pageCount: pages.pageNumber,
coverImage: pages.generatedImageUrl,
@@ -41,6 +42,7 @@ export async function GET() {
id: row.id,
title: row.title,
slug: row.slug,
style: row.style,
createdAt: row.createdAt,
pageCount: 0,
coverImage: null,
+23 -3
View File
@@ -319,6 +319,15 @@ export function StoryEditorClient() {
const result = await response.json();
// Update character images list with new ones
const newCharacterUrls = data.characterUrls || [];
setExistingCharacterImages((prev) => {
const combined = [...prev, ...newCharacterUrls];
// Remove duplicates while preserving order
const unique = Array.from(new Set(combined));
return unique;
});
setPages((prevPages) => [
...prevPages,
{
@@ -400,6 +409,17 @@ export function StoryEditorClient() {
onClose={() => setShowGenerateModal(false)}
onGenerate={handleGeneratePage}
pageNumber={pages.length + 1}
existingCharacters={existingCharacterImages}
lastPageCharacters={
pages.length > 0 && pages[pages.length - 1]?.characterUploads
? pages[pages.length - 1].characterUploads || []
: []
}
previousPageCharacters={
pages.length > 1 && pages[pages.length - 2]?.characterUploads
? pages[pages.length - 2].characterUploads || []
: []
}
/>
<PageInfoSheet
isOpen={showInfoSheet}
@@ -412,8 +432,9 @@ export function StoryEditorClient() {
<AlertDialogHeader>
<AlertDialogTitle>Delete Page</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete page {pageToDelete !== null ? pageToDelete + 1 : ""}?
This action cannot be undone.
Are you sure you want to delete page{" "}
{pageToDelete !== null ? pageToDelete + 1 : ""}? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -430,4 +451,3 @@ export function StoryEditorClient() {
</div>
);
}
+9 -7
View File
@@ -6,11 +6,13 @@ import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react";
import { Navbar } from "@/components/landing/navbar";
import { StoryLoader } from "@/components/ui/story-loader";
import { COMIC_STYLES } from "@/lib/constants";
interface Story {
id: string;
title: string;
slug: string;
style: string;
createdAt: string;
pageCount: number;
coverImage: string | null;
@@ -131,7 +133,7 @@ export default function StoriesPage() {
<button
key={story.id}
onClick={() => router.push(`/editor/${story.slug}`)}
className={`opacity-0 animate-fade-in-up group relative glass-panel p-3 rounded-lg hover:shadow-indigo/20 hover:shadow-2xl transition-all duration-500 hover:scale-[1.05] hover:-translate-y-2 border border-border/50 hover:border-indigo/30 hover:bg-white/[0.02] backdrop-blur-sm`}
className={`opacity-0 animate-fade-in-up group relative glass-panel p-3 rounded-lg hover:shadow-indigo/20 hover:shadow-2xl transition-all duration-500 hover:scale-[1.05] hover:-translate-y-2 border border-border/50 hover:border-indigo/30 hover:bg-white/[0.02] backdrop-blur-sm focus:outline-none`}
style={{ animationDelay: `${200 + index * 100}ms` }}
>
<div className="w-full h-full bg-neutral-900 border-4 border-black overflow-hidden relative group-hover:border-indigo/30 transition-colors duration-300">
@@ -157,14 +159,14 @@ export default function StoriesPage() {
{/* Subtle glow effect on hover */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-20 bg-gradient-to-r from-indigo/20 via-transparent to-emerald/20 transition-opacity duration-500" />
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/80 text-white text-[9px] font-mono uppercase tracking-widest border border-white/20 group-hover:bg-indigo/80 group-hover:border-indigo/40 transition-colors duration-300">
{story.pageCount}p
</div>
<div className="absolute top-2 right-2 px-1.5 py-0.5 bg-black/80 text-white text-[9px] font-mono uppercase tracking-widest border border-white/20 group-hover:bg-indigo/80 group-hover:border-indigo/40 transition-colors duration-300">
{COMIC_STYLES.find(s => s.id === story.style)?.name.toUpperCase() || story.style.toUpperCase()}
</div>
<div className="absolute bottom-0 left-0 right-0 p-3 text-left">
<h3 className="font-display text-sm text-white leading-tight line-clamp-2 mb-1 group-hover:text-indigo-100 transition-colors duration-300">
{story.title}
</h3>
<h3 className="font-display text-sm text-white leading-tight line-clamp-1 mb-1 group-hover:text-indigo-100 transition-colors duration-300">
{story.title}
</h3>
<p className="text-[10px] text-white/60 font-mono uppercase tracking-wider group-hover:text-white/80 transition-colors duration-300">
{new Date(story.createdAt).toLocaleDateString()}
</p>
+239 -73
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Upload, X, Loader2 } from "lucide-react";
import { Upload, X, Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -15,6 +15,13 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
import { useS3Upload } from "next-s3-upload";
interface CharacterItem {
url: string;
isNew?: boolean;
file?: File;
preview?: string;
}
interface GeneratePageModalProps {
isOpen: boolean;
onClose: () => void;
@@ -25,6 +32,9 @@ interface GeneratePageModalProps {
pageNumber: number;
isRedrawMode?: boolean;
existingPrompt?: string;
existingCharacters?: string[]; // All characters from the story
lastPageCharacters?: string[]; // Characters used on the last page
previousPageCharacters?: string[]; // Characters used on the previous page (if last page had < 2)
}
export function GeneratePageModal({
@@ -34,26 +44,80 @@ export function GeneratePageModal({
pageNumber,
isRedrawMode = false,
existingPrompt = "",
existingCharacters = [],
lastPageCharacters = [],
previousPageCharacters = [],
}: GeneratePageModalProps) {
const [prompt, setPrompt] = useState("");
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [showPreview, setShowPreview] = useState<number | null>(null);
const [characters, setCharacters] = useState<CharacterItem[]>([]);
const [selectedCharacterIndices, setSelectedCharacterIndices] = useState<
Set<number>
>(new Set());
const [showPreview, setShowPreview] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const { uploadToS3 } = useS3Upload();
// Reset form when modal opens
// Reset form and initialize characters when modal opens
useEffect(() => {
if (isOpen) {
setPrompt(isRedrawMode ? existingPrompt : "");
setUploadedFiles([]);
setPreviews([]);
setShowPreview(null);
setIsGenerating(false);
// Initialize characters list with existing ones
const existingItems: CharacterItem[] = existingCharacters.map((url) => ({
url,
isNew: false,
}));
setCharacters(existingItems);
// Smart selection: Use last 2 characters from last page, or combine with previous page if needed
const defaultSelected = new Set<number>();
const charactersToSelect: string[] = [];
// If last page has 2 characters, use those
if (lastPageCharacters.length >= 2) {
charactersToSelect.push(...lastPageCharacters.slice(0, 2));
} else {
// Start with last page characters (if any)
charactersToSelect.push(...lastPageCharacters);
// If we have less than 2, add from previous page (avoiding duplicates)
if (
charactersToSelect.length < 2 &&
previousPageCharacters.length > 0
) {
for (const charUrl of previousPageCharacters) {
if (
!charactersToSelect.includes(charUrl) &&
charactersToSelect.length < 2
) {
charactersToSelect.push(charUrl);
}
}
}
}
// Find indices of characters to select (preserving order in existingItems)
charactersToSelect.forEach((charUrl) => {
const index = existingItems.findIndex((item) => item.url === charUrl);
if (index !== -1) {
defaultSelected.add(index);
}
});
setSelectedCharacterIndices(defaultSelected);
}
}, [isOpen, isRedrawMode, existingPrompt]);
}, [
isOpen,
isRedrawMode,
existingPrompt,
existingCharacters,
lastPageCharacters,
previousPageCharacters,
]);
// Keyboard shortcut for form submission (disabled during generation)
useKeyboardShortcut(
@@ -92,34 +156,110 @@ export function GeneratePageModal({
if (validFiles.length === 0) return;
const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2);
setUploadedFiles(totalFiles);
const newPreviews = await Promise.all(
totalFiles.map((file) => generateFilePreview(file))
// Create new character items for the uploaded files
const newCharacterItems: CharacterItem[] = await Promise.all(
validFiles.map(async (file) => {
const preview = await generateFilePreview(file);
return {
url: "", // Will be set after S3 upload
isNew: true,
file,
preview,
};
})
);
setPreviews(newPreviews);
};
const removeFile = (index: number) => {
const newFiles = uploadedFiles.filter((_, i) => i !== index);
const newPreviews = previews.filter((_, i) => i !== index);
setUploadedFiles(newFiles);
setPreviews(newPreviews);
setShowPreview(null);
// Add new characters to the list
setCharacters((prev) => {
const updated = [...prev, ...newCharacterItems];
const newSelected = new Set(selectedCharacterIndices);
// Add new characters to selection
newCharacterItems.forEach((_, idx) => {
newSelected.add(prev.length + idx);
});
// If we have more than 2 selected, deselect the oldest ones (keep most recent 2)
if (newSelected.size > 2) {
const selectedArray = Array.from(newSelected).sort((a, b) => b - a);
const toKeep = selectedArray.slice(0, 2);
newSelected.clear();
toKeep.forEach((idx) => newSelected.add(idx));
}
setSelectedCharacterIndices(newSelected);
return updated;
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const toggleCharacterSelection = (index: number) => {
setSelectedCharacterIndices((prev) => {
const newSelected = new Set(prev);
if (newSelected.has(index)) {
// Allow deselection even if only 2 are selected
newSelected.delete(index);
} else {
// If already at max (2), remove the oldest selected first
if (newSelected.size >= 2) {
const selectedArray = Array.from(newSelected).sort((a, b) => a - b);
newSelected.delete(selectedArray[0]); // Remove oldest
}
newSelected.add(index);
}
return newSelected;
});
};
const removeCharacter = (index: number) => {
setCharacters((prev) => {
const updated = prev.filter((_, i) => i !== index);
// Adjust selected indices
setSelectedCharacterIndices((prevSelected) => {
const newSelected = new Set<number>();
prevSelected.forEach((idx) => {
if (idx < index) {
newSelected.add(idx);
} else if (idx > index) {
newSelected.add(idx - 1);
}
// Skip the removed index
});
return newSelected;
});
return updated;
});
setShowPreview(null);
};
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
try {
// Upload files to S3 and get URLs
// Get selected characters
const selectedCharacters = Array.from(selectedCharacterIndices)
.sort((a, b) => a - b)
.map((idx) => characters[idx])
.filter(Boolean);
// Upload new files to S3 and get URLs, reuse existing URLs
const characterUrls = await Promise.all(
uploadedFiles.map((file) => uploadToS3(file).then(({ url }) => url))
selectedCharacters.map(async (char) => {
if (char.isNew && char.file) {
// Upload new file to S3
const { url } = await uploadToS3(char.file);
return url;
} else {
// Reuse existing URL
return char.url;
}
})
);
await onGenerate({
@@ -191,60 +331,84 @@ export function GeneratePageModal({
/>
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
{/* Character Upload */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
{previews.length > 0 ? (
<div className="flex items-center gap-2">
{previews.map((preview, index) => (
<div key={index} className="relative group/thumb">
{/* Character Selection */}
<div className="space-y-2">
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
Characters (select up to 2)
</label>
{/* Existing and new characters list */}
{characters.length > 0 && (
<div className="flex flex-wrap gap-2">
{characters.map((char, index) => {
const isSelected =
selectedCharacterIndices.has(index);
const imageUrl = char.preview || char.url;
return (
<div
key={index}
className={`relative group/thumb cursor-pointer ${
isSelected ? "ring-2 ring-indigo-500" : ""
}`}
>
<button
onClick={() => setShowPreview(index)}
className="w-8 h-8 rounded-md overflow-hidden border border-border/50 hover:border-indigo/50 transition-colors"
type="button"
onClick={() => toggleCharacterSelection(index)}
onDoubleClick={() => setShowPreview(imageUrl)}
disabled={isGenerating}
className="w-10 h-10 rounded-md overflow-hidden border-2 border-transparent hover:border-indigo/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed relative"
title="Click to select/deselect, double-click to preview"
>
<img
src={preview || "/placeholder.svg"}
alt={`New character ${index + 1}`}
src={imageUrl || "/placeholder.svg"}
alt={`Character ${index + 1}`}
className="w-full h-full object-cover"
/>
{/* Selection indicator */}
{isSelected && (
<div className="absolute top-0 right-0 w-4 h-4 bg-indigo-500 rounded-full flex items-center justify-center pointer-events-none z-10 border-2 border-background">
<Check className="w-2.5 h-2.5 text-white" />
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
disabled={isGenerating}
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"
>
<X className="w-2.5 h-2.5 text-white" />
</button>
{/* Remove button (only for new uploads) */}
{char.isNew && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeCharacter(index);
}}
disabled={isGenerating}
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 z-20"
>
<X className="w-2.5 h-2.5 text-white" />
</button>
)}
</div>
))}
{uploadedFiles.length < 2 && (
<button
onClick={() =>
!isGenerating && fileInputRef.current?.click()
}
disabled={isGenerating}
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"
>
<Upload className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
<button
onClick={() =>
!isGenerating && fileInputRef.current?.click()
}
disabled={isGenerating}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-3.5 h-3.5" />
<span>Add new characters (optional)</span>
</button>
)}
</div>
);
})}
</div>
)}
{/* Upload new character button */}
<button
type="button"
onClick={() =>
!isGenerating && fileInputRef.current?.click()
}
disabled={isGenerating}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-3.5 h-3.5" />
<span>
{characters.length === 0
? "Add characters (optional, max 2)"
: "Upload new character"}
</span>
</button>
</div>
<input
@@ -262,7 +426,9 @@ export function GeneratePageModal({
<div className="text-xs text-muted-foreground/70">
{isRedrawMode
? "This will replace the current page with a new version. Previous pages and characters are automatically referenced."
: "Automatically references previous pages and existing characters from your story."}
: selectedCharacterIndices.size > 0
? `${selectedCharacterIndices.size} character(s) selected. Click to toggle selection.`
: "No characters selected. Click on characters above to select them, or upload new ones."}
</div>
<Button
@@ -286,7 +452,7 @@ export function GeneratePageModal({
</Dialog>
{/* Character Preview Modal */}
{showPreview !== null && previews[showPreview] && (
{showPreview && (
<div
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-100 flex items-center justify-center p-4"
onClick={() => setShowPreview(null)}
@@ -301,7 +467,7 @@ export function GeneratePageModal({
<X className="w-4 h-4" />
</Button>
<img
src={previews[showPreview] || "/placeholder.svg"}
src={showPreview || "/placeholder.svg"}
alt="Character preview"
className="w-full h-full object-contain rounded-lg"
/>