forked from 77media/video-flow
统一创建入口:图片预览/替换
This commit is contained in:
parent
b82eda136f
commit
1662ee026c
334
components/pages/create-video/CreateInput/AddItemModal.tsx
Normal file
334
components/pages/create-video/CreateInput/AddItemModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user