统一创建入口:图片预览/替换

This commit is contained in:
北枳 2025-10-20 14:43:01 +08:00
parent b82eda136f
commit 1662ee026c
4 changed files with 537 additions and 47 deletions

View File

@ -0,0 +1,334 @@
"use client";
import { useState, useRef, useEffect } from 'react';
import { Modal, Upload, message } from 'antd';
import NextImage from 'next/image';
import {
CloseOutlined,
UserOutlined,
CameraOutlined,
BulbOutlined,
CloudUploadOutlined,
CheckOutlined
} from '@ant-design/icons';
import type { PhotoType } from '../PhotoPreview/types';
interface AddItemModalProps {
/** Modal visibility state */
visible: boolean;
/** Close handler */
onClose: () => void;
/** Type of item to add */
itemType: PhotoType | null;
/** Submit handler */
onSubmit: (data: { name: string; description: string; file: File; index?: number }) => void;
/** Edit mode - initial data for editing */
editData?: {
index: number;
name?: string;
description?: string;
imageUrl?: string;
};
}
/**
* Modern modal for adding/editing items (character, scene, prop) with name, description, and image.
* Features a card-based, non-traditional form design matching the app theme.
*/
export function AddItemModal({
visible,
onClose,
itemType,
onSubmit,
editData,
}: AddItemModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isEditMode = !!editData;
/** Initialize form with edit data */
useEffect(() => {
if (visible && editData) {
setName(editData.name || '');
setDescription(editData.description || '');
setImagePreview(editData.imageUrl || null);
setImageFile(null);
} else if (visible) {
setName('');
setDescription('');
setImagePreview(null);
setImageFile(null);
}
}, [visible, editData]);
/** Get item configuration based on type */
const getItemConfig = () => {
const prefix = isEditMode ? 'Edit' : 'Add';
switch (itemType) {
case 'character':
return {
icon: <UserOutlined className="text-2xl" />,
title: `${prefix} Character`,
namePlaceholder: 'Character name',
descriptionPlaceholder: 'Describe the character\'s appearance, personality, or key features...',
};
case 'scene':
return {
icon: <CameraOutlined className="text-2xl" />,
title: `${prefix} Scene`,
namePlaceholder: 'Scene name',
descriptionPlaceholder: 'Describe the location, atmosphere, or visual elements...',
};
case 'prop':
return {
icon: <BulbOutlined className="text-2xl" />,
title: `${prefix} Prop`,
namePlaceholder: 'Prop name',
descriptionPlaceholder: 'Describe the object\'s appearance, function, or significance...',
};
default:
return {
icon: null,
title: `${prefix} Item`,
namePlaceholder: 'Item name',
descriptionPlaceholder: 'Add a description...',
};
}
};
const config = getItemConfig();
/** Handle file selection */
const handleFileSelect = (file: File) => {
if (!file.type.startsWith('image/')) {
message.error('Please upload an image file');
return;
}
setImageFile(file);
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
};
/** Handle drag and drop */
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) {
handleFileSelect(file);
}
};
/** Handle form submission */
const handleSubmit = () => {
if (!name.trim()) {
message.warning('Please enter a name');
return;
}
// In edit mode, if no new file is selected, we need to use existing image
if (!imageFile && !imagePreview) {
message.warning('Please upload an image');
return;
}
// If editing and no new file, create a dummy file for the existing image
const submitFile = imageFile || new File([], 'existing-image');
onSubmit({
name: name.trim(),
description: description.trim(),
file: submitFile,
...(isEditMode && editData && { index: editData.index }),
});
// Reset form
setName('');
setDescription('');
setImageFile(null);
setImagePreview(null);
onClose();
};
/** Handle modal close */
const handleClose = () => {
setName('');
setDescription('');
setImageFile(null);
setImagePreview(null);
onClose();
};
return (
<Modal
open={visible}
onCancel={handleClose}
footer={null}
closeIcon={<CloseOutlined className="text-gray-400 hover:text-cyan-400" />}
width="90%"
style={{ maxWidth: '480px' }}
styles={{
mask: { backdropFilter: 'blur(4px)', backgroundColor: 'rgba(0, 0, 0, 0.5)' },
content: {
padding: 0,
backdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '24px',
overflow: 'hidden',
},
}}
>
<div data-alt="add-item-modal" className="relative">
{/* Header with gradient */}
<div
data-alt="modal-header"
className="px-4 py-3 bg-gradient-to-r from-cyan-500/10 to-blue-500/10 border-b border-white/10"
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-cyan-400/20 border border-cyan-400/40 flex items-center justify-center text-cyan-400">
{config.icon}
</div>
<h2 className="text-base font-semibold text-white">{config.title}</h2>
</div>
</div>
{/* Content */}
<div data-alt="modal-content" className="p-4 space-y-4">
{/* Image Upload Area */}
<div data-alt="image-upload-section">
<div className="flex items-center gap-2 mb-2">
<CloudUploadOutlined className="text-cyan-400 text-sm" />
<span className="text-xs text-gray-300">Image</span>
</div>
<div
data-alt="upload-area"
className={`relative rounded-2xl border-2 border-dashed transition-all duration-300 overflow-hidden ${
isDragging
? 'border-cyan-400 bg-cyan-400/10'
: imagePreview
? 'border-cyan-400/40 bg-black/20'
: 'border-white/20 bg-black/20 hover:border-cyan-400/60 hover:bg-black/30'
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
style={{ cursor: 'pointer', aspectRatio: '16/9' }}
>
{imagePreview ? (
<div className="relative w-full h-full group">
<NextImage
src={imagePreview}
alt="Preview"
fill
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
<span className="text-sm text-white">Click to change</span>
</div>
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-cyan-400 flex items-center justify-center">
<CheckOutlined className="text-xs text-black" />
</div>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center px-6">
<CloudUploadOutlined className={`text-4xl transition-colors duration-200 ${
isDragging ? 'text-cyan-400' : 'text-gray-400'
}`} />
<div>
<p className="text-sm text-gray-300 mb-1">
Click to upload or drag & drop
</p>
<p className="text-xs text-gray-500">
PNG, JPG, WEBP up to 10MB
</p>
</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
</div>
{/* Name Input */}
<div data-alt="name-section">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3 bg-cyan-400 rounded-full" />
<span className="text-xs text-gray-300">Name</span>
</div>
<input
data-alt="name-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={config.namePlaceholder}
className="w-full px-3 py-2 bg-black/30 border border-white/10 rounded-lg text-gray-200 placeholder-gray-500 outline-none focus:border-cyan-400/60 focus:bg-black/40 transition-all duration-200 text-sm"
maxLength={50}
/>
</div>
{/* Description Input */}
<div data-alt="description-section">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-3 bg-cyan-400 rounded-full" />
<span className="text-xs text-gray-300">Description</span>
<span className="text-xs text-gray-500">(Optional)</span>
</div>
<textarea
data-alt="description-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={config.descriptionPlaceholder}
rows={3}
className="w-full px-3 py-2 bg-black/30 border border-white/10 rounded-lg text-gray-200 placeholder-gray-500 outline-none focus:border-cyan-400/60 focus:bg-black/40 transition-all duration-200 resize-none text-sm"
maxLength={200}
/>
</div>
</div>
{/* Footer with Actions */}
<div data-alt="modal-footer" className="px-4 py-3 bg-black/30 border-t border-white/10 flex items-center justify-end gap-2">
<button
data-alt="cancel-button"
onClick={handleClose}
className="px-4 py-2 rounded-lg bg-transparent border border-white/20 text-gray-300 hover:bg-white/5 hover:border-white/30 hover:text-white transition-all duration-200 text-xs font-medium"
>
Cancel
</button>
<button
data-alt="submit-button"
onClick={handleSubmit}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-white shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/40 transition-all duration-200 text-xs font-medium"
>
{isEditMode ? 'Save Changes' : `Add ${itemType ? itemType.charAt(0).toUpperCase() + itemType.slice(1) : 'Item'}`}
</button>
</div>
</div>
</Modal>
);
}

View File

@ -14,6 +14,7 @@ import {
import { Dropdown } from 'antd';
import { ConfigPanel } from './ConfigPanel';
import { MobileConfigModal } from './MobileConfigModal';
import { AddItemModal } from './AddItemModal';
import { defaultConfig } from './config-options';
import type { ConfigOptions } from './config-options';
import { useDeviceType } from '@/hooks/useDeviceType';
@ -23,6 +24,10 @@ export default function VideoCreationForm() {
const [inputText, setInputText] = useState('');
const [configOptions, setConfigOptions] = useState<ConfigOptions>(defaultConfig);
const [configModalVisible, setConfigModalVisible] = useState(false);
const [addItemModalVisible, setAddItemModalVisible] = useState(false);
const [currentItemType, setCurrentItemType] = useState<PhotoType | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
const { isMobile, isDesktop } = useDeviceType();
@ -30,23 +35,34 @@ export default function VideoCreationForm() {
const sceneInputRef = useRef<HTMLInputElement>(null);
const propInputRef = useRef<HTMLInputElement>(null);
/** Handle photo deletion */
const handleDeletePhoto = (index: number) => {
setPhotos(prevPhotos => prevPhotos.filter((_, i) => i !== index));
};
/** Handle file upload */
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>, type: PhotoType) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const newPhotos: PhotoItem[] = Array.from(files).map((file, index) => ({
url: URL.createObjectURL(file),
type,
id: `${type}-${Date.now()}-${index}`,
}));
// Check if we're replacing an existing photo
if (replacingIndex !== null) {
const file = files[0];
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
updatedPhotos[replacingIndex] = {
...updatedPhotos[replacingIndex],
url: URL.createObjectURL(file),
};
return updatedPhotos;
});
setReplacingIndex(null);
} else {
// Add new photos
const newPhotos: PhotoItem[] = Array.from(files).map((file, index) => ({
url: URL.createObjectURL(file),
type,
id: `${type}-${Date.now()}-${index}`,
}));
setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]);
}
setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]);
event.target.value = '';
};
@ -58,6 +74,96 @@ export default function VideoCreationForm() {
setConfigOptions(prev => ({ ...prev, [key]: value }));
};
/** Handle opening add item modal */
const handleOpenAddItemModal = (type: PhotoType) => {
setCurrentItemType(type);
setEditingIndex(null);
setAddItemModalVisible(true);
};
/** Handle editing a photo */
const handleEditPhoto = (index: number) => {
const photo = photos[index];
setCurrentItemType(photo.type);
setEditingIndex(index);
setAddItemModalVisible(true);
};
/** Handle replacing a photo */
const handleReplacePhoto = (index: number) => {
const photo = photos[index];
setReplacingIndex(index);
// Trigger the appropriate file input based on photo type
switch (photo.type) {
case 'character':
characterInputRef.current?.click();
break;
case 'scene':
sceneInputRef.current?.click();
break;
case 'prop':
propInputRef.current?.click();
break;
}
};
/** Handle adding photos by type */
const handleAddPhotoByType = (type: PhotoType) => {
// Reset replacing index to ensure we're adding, not replacing
setReplacingIndex(null);
// Trigger the appropriate file input
switch (type) {
case 'character':
characterInputRef.current?.click();
break;
case 'scene':
sceneInputRef.current?.click();
break;
case 'prop':
propInputRef.current?.click();
break;
}
};
/** Handle item submission from modal */
const handleItemSubmit = (data: { name: string; description: string; file: File; index?: number }) => {
if (!currentItemType) return;
// Check if we're editing or adding
if (typeof data.index === 'number' && editingIndex !== null) {
// Edit mode - update existing photo
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
const existingPhoto = updatedPhotos[editingIndex];
updatedPhotos[editingIndex] = {
...existingPhoto,
// Only update URL if a new file was selected (not the dummy file)
...(data.file.size > 0 && { url: URL.createObjectURL(data.file) }),
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
return updatedPhotos;
});
console.log('Updated item:', { ...data, type: currentItemType, index: editingIndex });
} else {
// Add mode - create new photo
const newPhoto: PhotoItem = {
url: URL.createObjectURL(data.file),
type: currentItemType,
id: `${currentItemType}-${Date.now()}`,
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
setPhotos(prevPhotos => [...prevPhotos, newPhoto]);
console.log('Added item:', { ...data, type: currentItemType });
}
};
/** Handle video creation */
const handleCreate = () => {
console.log({
@ -80,7 +186,8 @@ export default function VideoCreationForm() {
<div data-alt="photo-preview-wrapper" className="p-4 pb-0">
<PhotoPreviewSection
photos={photos}
onDelete={handleDeletePhoto}
onEdit={handleEditPhoto}
onReplace={handleReplacePhoto}
/>
</div>
)}
@ -108,19 +215,19 @@ export default function VideoCreationForm() {
key: 'character',
icon: <UserOutlined className="text-cyan-400" />,
label: <span className="text-gray-300">Character</span>,
onClick: () => characterInputRef.current?.click(),
onClick: () => handleAddPhotoByType('character'),
},
{
key: 'scene',
icon: <CameraOutlined className="text-cyan-400" />,
label: <span className="text-gray-300">Scene</span>,
onClick: () => sceneInputRef.current?.click(),
onClick: () => handleAddPhotoByType('scene'),
},
{
key: 'prop',
icon: <BulbOutlined className="text-cyan-400" />,
label: <span className="text-gray-300">Prop</span>,
onClick: () => propInputRef.current?.click(),
onClick: () => handleAddPhotoByType('prop'),
},
],
className: 'bg-[#1a1a1a] border border-white/10'
@ -206,6 +313,28 @@ export default function VideoCreationForm() {
configOptions={configOptions}
onConfigChange={handleConfigChange}
/>
{/* Add Item Modal */}
<AddItemModal
visible={addItemModalVisible}
onClose={() => {
setAddItemModalVisible(false);
setCurrentItemType(null);
setEditingIndex(null);
}}
itemType={currentItemType}
onSubmit={handleItemSubmit}
editData={
editingIndex !== null && photos[editingIndex]
? {
index: editingIndex,
name: photos[editingIndex].name,
description: photos[editingIndex].description,
imageUrl: photos[editingIndex].url,
}
: undefined
}
/>
</div>
);
}

View File

@ -2,20 +2,23 @@
import { useState } from 'react';
import { Image } from 'antd';
import { CloseOutlined, UserOutlined, CameraOutlined, BulbOutlined } from '@ant-design/icons';
import NextImage from 'next/image';
import { EyeOutlined, SwapOutlined, UserOutlined, CameraOutlined, BulbOutlined } from '@ant-design/icons';
import type { PhotoPreviewSectionProps, PhotoType } from './types';
import './styles.css';
/**
* Photo Preview Section Component
* Displays a horizontal list of photos with type indicators and delete functionality
* Displays a horizontal list of photos with type indicators and preview/replace functionality
* @param photos - Array of photos with type information
* @param onDelete - Callback when a photo is deleted
* @param onEdit - Callback when a photo is clicked for editing
* @param onReplace - Callback when replace button is clicked
* @param className - Additional CSS classes
*/
export default function PhotoPreviewSection({
photos = [],
onDelete,
onEdit,
onReplace,
className = '',
}: PhotoPreviewSectionProps) {
const [previewVisible, setPreviewVisible] = useState(false);
@ -63,10 +66,19 @@ export default function PhotoPreviewSection({
};
/**
* Handle photo deletion
* Handle preview button click
*/
const handleDelete = (index: number) => {
onDelete?.(index);
const handlePreviewClick = (e: React.MouseEvent, url: string) => {
e.stopPropagation();
handlePreview(url);
};
/**
* Handle replace button click
*/
const handleReplaceClick = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
onReplace?.(index);
};
/** Don't render if no photos */
@ -85,43 +97,52 @@ export default function PhotoPreviewSection({
<div
key={photo.id || `photo-${index}`}
data-alt="photo-item"
className="relative flex-shrink-0 w-16 h-16 rounded-[16px] overflow-visible border border-white/10 hover:border-cyan-400/60 transition-all duration-200 cursor-pointer group bg-black/20"
className="relative flex-shrink-0 w-16 h-16 rounded-[16px] overflow-hidden border border-white/10 hover:border-cyan-400/60 transition-all duration-200 group bg-black/20"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
{/* Photo Image */}
<div
className="w-full h-full rounded-[16px] overflow-hidden"
onClick={() => handlePreview(photo.url)}
>
<img
<div className="w-full h-full relative">
<NextImage
src={photo.url}
alt={`${getTypeLabel(photo.type)} ${index + 1}`}
className="w-full h-full object-cover"
fill
className="object-cover"
unoptimized
/>
</div>
{/* Hover Overlay with Preview and Replace Icons */}
<div
data-alt="hover-overlay"
className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-1 z-[5]"
>
{/* Preview Button */}
<button
data-alt="preview-button"
className="w-5 h-5 rounded-full bg-cyan-400/90 hover:bg-cyan-400 flex items-center justify-center transition-all duration-200 shadow-lg"
onClick={(e) => handlePreviewClick(e, photo.url)}
>
<EyeOutlined className="text-xs text-black" />
</button>
{/* Replace Button */}
<button
data-alt="replace-button"
className="w-5 h-5 rounded-full bg-cyan-400/90 hover:bg-cyan-400 flex items-center justify-center transition-all duration-200 shadow-lg"
onClick={(e) => handleReplaceClick(e, index)}
>
<SwapOutlined className="text-xs text-black" />
</button>
</div>
{/* Type Icon - Bottom Left Corner */}
<div
data-alt="type-icon"
className="absolute bottom-1 left-1 w-4 h-4 bg-black/60 backdrop-blur-sm text-cyan-400 rounded-sm flex items-center justify-center z-[5]"
className="absolute bottom-1 left-1 w-4 h-4 bg-black/60 backdrop-blur-sm text-cyan-400 rounded-sm flex items-center justify-center z-[6]"
>
{getTypeIcon(photo.type)}
</div>
{/* Delete Button - Top Right Corner */}
{hoveredIndex === index && (
<button
data-alt="delete-button"
className="absolute top-1 right-1 p-1 bg-[#636364] opacity-80 hover:opacity-100 text-white rounded-full flex items-center justify-center transition-all duration-200 shadow-lg hover:scale-110 z-10"
onClick={(e) => {
e.stopPropagation();
handleDelete(index);
}}
>
<CloseOutlined className="text-[10px]" />
</button>
)}
</div>
))}
</div>
@ -132,6 +153,7 @@ export default function PhotoPreviewSection({
width={0}
height={0}
src={previewImage}
alt="Preview"
preview={{
visible: previewVisible,
src: previewImage,

View File

@ -11,15 +11,20 @@ export interface PhotoItem {
type: PhotoType;
/** Optional unique identifier */
id?: string;
/** Optional name for the item */
name?: string;
/** Optional description for the item */
description?: string;
}
/** Props for PhotoPreviewSection component */
export interface PhotoPreviewSectionProps {
/** List of photos with type information */
photos: PhotoItem[];
/** Callback when a photo is deleted */
onDelete?: (index: number) => void;
/** Callback when a photo is clicked for editing */
onEdit?: (index: number) => void;
/** Callback when replace button is clicked */
onReplace?: (index: number) => void;
/** Custom class name */
className?: string;
}