Use frontend-selected characters instead of fetching recent ones
This commit is contained in:
@@ -10,7 +10,6 @@ import {
|
|||||||
getNextPageNumber,
|
getNextPageNumber,
|
||||||
getStoryWithPagesBySlug,
|
getStoryWithPagesBySlug,
|
||||||
getLastPageImage,
|
getLastPageImage,
|
||||||
getStoryCharacterImages,
|
|
||||||
} from "@/lib/db-actions";
|
} from "@/lib/db-actions";
|
||||||
import { freeTierRateLimit } from "@/lib/rate-limit";
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
import { uploadImageToS3 } from "@/lib/s3-upload";
|
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)
|
// Use only the character images sent from the frontend (user's selection)
|
||||||
const storyCharacterImages = await getStoryCharacterImages(story.id);
|
// These are already the most recent/relevant characters the user wants to use
|
||||||
referenceImages.push(...storyCharacterImages.slice(-2)); // Take last 2
|
|
||||||
|
|
||||||
// Add current character images to references
|
|
||||||
referenceImages.push(...characterImages);
|
referenceImages.push(...characterImages);
|
||||||
|
|
||||||
// Build the prompt with continuation context
|
// Build the prompt with continuation context
|
||||||
|
|||||||
@@ -119,9 +119,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get story character images (up to 2 most recent)
|
// For continuation pages, character images are sent from frontend
|
||||||
const storyCharacterImages = await getStoryCharacterImages(storyId);
|
// No need to fetch separately - frontend handles selection
|
||||||
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
|
// 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);
|
referenceImages.push(...characterImages);
|
||||||
|
|
||||||
const dimensions = FIXED_DIMENSIONS;
|
const dimensions = FIXED_DIMENSIONS;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function GET() {
|
|||||||
id: stories.id,
|
id: stories.id,
|
||||||
title: stories.title,
|
title: stories.title,
|
||||||
slug: stories.slug,
|
slug: stories.slug,
|
||||||
|
style: stories.style,
|
||||||
createdAt: stories.createdAt,
|
createdAt: stories.createdAt,
|
||||||
pageCount: pages.pageNumber,
|
pageCount: pages.pageNumber,
|
||||||
coverImage: pages.generatedImageUrl,
|
coverImage: pages.generatedImageUrl,
|
||||||
@@ -41,6 +42,7 @@ export async function GET() {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
|
style: row.style,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
pageCount: 0,
|
pageCount: 0,
|
||||||
coverImage: null,
|
coverImage: null,
|
||||||
|
|||||||
@@ -319,6 +319,15 @@ export function StoryEditorClient() {
|
|||||||
|
|
||||||
const result = await response.json();
|
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) => [
|
setPages((prevPages) => [
|
||||||
...prevPages,
|
...prevPages,
|
||||||
{
|
{
|
||||||
@@ -400,6 +409,17 @@ export function StoryEditorClient() {
|
|||||||
onClose={() => setShowGenerateModal(false)}
|
onClose={() => setShowGenerateModal(false)}
|
||||||
onGenerate={handleGeneratePage}
|
onGenerate={handleGeneratePage}
|
||||||
pageNumber={pages.length + 1}
|
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
|
<PageInfoSheet
|
||||||
isOpen={showInfoSheet}
|
isOpen={showInfoSheet}
|
||||||
@@ -412,8 +432,9 @@ export function StoryEditorClient() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete Page</AlertDialogTitle>
|
<AlertDialogTitle>Delete Page</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete page {pageToDelete !== null ? pageToDelete + 1 : ""}?
|
Are you sure you want to delete page{" "}
|
||||||
This action cannot be undone.
|
{pageToDelete !== null ? pageToDelete + 1 : ""}? This action
|
||||||
|
cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -430,4 +451,3 @@ export function StoryEditorClient() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Plus, Loader2 } from "lucide-react";
|
import { Plus, Loader2 } from "lucide-react";
|
||||||
import { Navbar } from "@/components/landing/navbar";
|
import { Navbar } from "@/components/landing/navbar";
|
||||||
import { StoryLoader } from "@/components/ui/story-loader";
|
import { StoryLoader } from "@/components/ui/story-loader";
|
||||||
|
import { COMIC_STYLES } from "@/lib/constants";
|
||||||
|
|
||||||
interface Story {
|
interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
style: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
@@ -131,7 +133,7 @@ export default function StoriesPage() {
|
|||||||
<button
|
<button
|
||||||
key={story.id}
|
key={story.id}
|
||||||
onClick={() => router.push(`/editor/${story.slug}`)}
|
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` }}
|
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">
|
<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 */}
|
{/* 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 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">
|
<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
|
{COMIC_STYLES.find(s => s.id === story.style)?.name.toUpperCase() || story.style.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-3 text-left">
|
<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">
|
<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}
|
{story.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-white/60 font-mono uppercase tracking-wider group-hover:text-white/80 transition-colors duration-300">
|
<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()}
|
{new Date(story.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,6 +15,13 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
|
|||||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
||||||
import { useS3Upload } from "next-s3-upload";
|
import { useS3Upload } from "next-s3-upload";
|
||||||
|
|
||||||
|
interface CharacterItem {
|
||||||
|
url: string;
|
||||||
|
isNew?: boolean;
|
||||||
|
file?: File;
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneratePageModalProps {
|
interface GeneratePageModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -25,6 +32,9 @@ interface GeneratePageModalProps {
|
|||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
isRedrawMode?: boolean;
|
isRedrawMode?: boolean;
|
||||||
existingPrompt?: string;
|
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({
|
export function GeneratePageModal({
|
||||||
@@ -34,26 +44,80 @@ export function GeneratePageModal({
|
|||||||
pageNumber,
|
pageNumber,
|
||||||
isRedrawMode = false,
|
isRedrawMode = false,
|
||||||
existingPrompt = "",
|
existingPrompt = "",
|
||||||
|
existingCharacters = [],
|
||||||
|
lastPageCharacters = [],
|
||||||
|
previousPageCharacters = [],
|
||||||
}: GeneratePageModalProps) {
|
}: GeneratePageModalProps) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
const [characters, setCharacters] = useState<CharacterItem[]>([]);
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
const [selectedCharacterIndices, setSelectedCharacterIndices] = useState<
|
||||||
const [showPreview, setShowPreview] = useState<number | null>(null);
|
Set<number>
|
||||||
|
>(new Set());
|
||||||
|
const [showPreview, setShowPreview] = useState<string | null>(null);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { uploadToS3 } = useS3Upload();
|
const { uploadToS3 } = useS3Upload();
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form and initialize characters when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setPrompt(isRedrawMode ? existingPrompt : "");
|
setPrompt(isRedrawMode ? existingPrompt : "");
|
||||||
setUploadedFiles([]);
|
|
||||||
setPreviews([]);
|
|
||||||
setShowPreview(null);
|
setShowPreview(null);
|
||||||
setIsGenerating(false);
|
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)
|
// Keyboard shortcut for form submission (disabled during generation)
|
||||||
useKeyboardShortcut(
|
useKeyboardShortcut(
|
||||||
@@ -92,34 +156,110 @@ export function GeneratePageModal({
|
|||||||
|
|
||||||
if (validFiles.length === 0) return;
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2);
|
// Create new character items for the uploaded files
|
||||||
setUploadedFiles(totalFiles);
|
const newCharacterItems: CharacterItem[] = await Promise.all(
|
||||||
|
validFiles.map(async (file) => {
|
||||||
const newPreviews = await Promise.all(
|
const preview = await generateFilePreview(file);
|
||||||
totalFiles.map((file) => generateFilePreview(file))
|
return {
|
||||||
|
url: "", // Will be set after S3 upload
|
||||||
|
isNew: true,
|
||||||
|
file,
|
||||||
|
preview,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
setPreviews(newPreviews);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
// Add new characters to the list
|
||||||
const newFiles = uploadedFiles.filter((_, i) => i !== index);
|
setCharacters((prev) => {
|
||||||
const newPreviews = previews.filter((_, i) => i !== index);
|
const updated = [...prev, ...newCharacterItems];
|
||||||
setUploadedFiles(newFiles);
|
const newSelected = new Set(selectedCharacterIndices);
|
||||||
setPreviews(newPreviews);
|
|
||||||
setShowPreview(null);
|
// 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) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
if (!prompt.trim()) return;
|
if (!prompt.trim()) return;
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
|
||||||
try {
|
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(
|
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({
|
await onGenerate({
|
||||||
@@ -191,60 +331,84 @@ export function GeneratePageModal({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
||||||
{/* Character Upload */}
|
{/* Character Selection */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
|
||||||
{previews.length > 0 ? (
|
Characters (select up to 2)
|
||||||
<div className="flex items-center gap-2">
|
</label>
|
||||||
{previews.map((preview, index) => (
|
|
||||||
<div key={index} className="relative group/thumb">
|
{/* 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
|
<button
|
||||||
onClick={() => setShowPreview(index)}
|
type="button"
|
||||||
className="w-8 h-8 rounded-md overflow-hidden border border-border/50 hover:border-indigo/50 transition-colors"
|
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
|
<img
|
||||||
src={preview || "/placeholder.svg"}
|
src={imageUrl || "/placeholder.svg"}
|
||||||
alt={`New character ${index + 1}`}
|
alt={`Character ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
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>
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
{/* Remove button (only for new uploads) */}
|
||||||
e.stopPropagation();
|
{char.isNew && (
|
||||||
removeFile(index);
|
<button
|
||||||
}}
|
type="button"
|
||||||
disabled={isGenerating}
|
onClick={(e) => {
|
||||||
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"
|
e.stopPropagation();
|
||||||
>
|
removeCharacter(index);
|
||||||
<X className="w-2.5 h-2.5 text-white" />
|
}}
|
||||||
</button>
|
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>
|
</div>
|
||||||
))}
|
);
|
||||||
{uploadedFiles.length < 2 && (
|
})}
|
||||||
<button
|
</div>
|
||||||
onClick={() =>
|
)}
|
||||||
!isGenerating && fileInputRef.current?.click()
|
|
||||||
}
|
{/* Upload new character button */}
|
||||||
disabled={isGenerating}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() =>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
!isGenerating && fileInputRef.current?.click()
|
||||||
</button>
|
}
|
||||||
)}
|
disabled={isGenerating}
|
||||||
</div>
|
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
) : (
|
>
|
||||||
<button
|
<Upload className="w-3.5 h-3.5" />
|
||||||
onClick={() =>
|
<span>
|
||||||
!isGenerating && fileInputRef.current?.click()
|
{characters.length === 0
|
||||||
}
|
? "Add characters (optional, max 2)"
|
||||||
disabled={isGenerating}
|
: "Upload new character"}
|
||||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
</span>
|
||||||
>
|
</button>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
|
||||||
<span>Add new characters (optional)</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -262,7 +426,9 @@ export function GeneratePageModal({
|
|||||||
<div className="text-xs text-muted-foreground/70">
|
<div className="text-xs text-muted-foreground/70">
|
||||||
{isRedrawMode
|
{isRedrawMode
|
||||||
? "This will replace the current page with a new version. Previous pages and characters are automatically referenced."
|
? "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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -286,7 +452,7 @@ export function GeneratePageModal({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Character Preview Modal */}
|
{/* Character Preview Modal */}
|
||||||
{showPreview !== null && previews[showPreview] && (
|
{showPreview && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-100 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-100 flex items-center justify-center p-4"
|
||||||
onClick={() => setShowPreview(null)}
|
onClick={() => setShowPreview(null)}
|
||||||
@@ -301,7 +467,7 @@ export function GeneratePageModal({
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<img
|
<img
|
||||||
src={previews[showPreview] || "/placeholder.svg"}
|
src={showPreview || "/placeholder.svg"}
|
||||||
alt="Character preview"
|
alt="Character preview"
|
||||||
className="w-full h-full object-contain rounded-lg"
|
className="w-full h-full object-contain rounded-lg"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user