2025-10-20 14:43:08 +08:00

335 lines
14 KiB
TypeScript

"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>
);
}