forked from 77media/video-flow
统一create输入
This commit is contained in:
parent
236051a91c
commit
1c2421e2b6
10
app/create/page.tsx
Normal file
10
app/create/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import CreateVideo from '@/components/pages/create-video/CreateVideo';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<CreateVideo />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { BookHeart, Gift } from "lucide-react";
|
||||
import { BookHeart, Gift, Plus } from "lucide-react";
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
@ -17,6 +17,7 @@ export const navigationItems: Navigations[] = [
|
||||
items: [
|
||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||
{ name: 'Share', href: '/share', icon: Gift },
|
||||
{ name: 'Create', href: '/create', icon: Plus },
|
||||
],
|
||||
}
|
||||
];
|
||||
145
components/pages/create-video/CreateInput/ConfigPanel.tsx
Normal file
145
components/pages/create-video/CreateInput/ConfigPanel.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
GlobalOutlined,
|
||||
ClockCircleOutlined,
|
||||
DownOutlined,
|
||||
MobileOutlined,
|
||||
DesktopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { WandSparkles, RectangleHorizontal, RectangleVertical } from 'lucide-react';
|
||||
import { Dropdown, Menu, Tooltip } from 'antd';
|
||||
import { LanguageOptions, VideoDurationOptions } from './config-options';
|
||||
import type { ConfigOptions, LanguageValue, VideoDurationValue } from './config-options';
|
||||
|
||||
interface ConfigPanelProps {
|
||||
/** Current configuration options */
|
||||
configOptions: ConfigOptions;
|
||||
/** Handler for configuration changes */
|
||||
onConfigChange: <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => void;
|
||||
/** Whether it's mobile device */
|
||||
isMobile?: boolean;
|
||||
/** Whether it's desktop device */
|
||||
isDesktop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration panel component for video creation settings.
|
||||
* Includes language, auto script, duration, and aspect ratio selectors.
|
||||
* @param {ConfigOptions} configOptions - current configuration
|
||||
* @param {Function} onConfigChange - handler for config changes
|
||||
* @param {boolean} isMobile - whether it's mobile device
|
||||
* @param {boolean} isDesktop - whether it's desktop device
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export const ConfigPanel = ({
|
||||
configOptions,
|
||||
onConfigChange,
|
||||
isMobile = false,
|
||||
isDesktop = true,
|
||||
}: ConfigPanelProps) => {
|
||||
/** Language dropdown menu */
|
||||
const languageMenu = (
|
||||
<Menu
|
||||
className="bg-[#1a1a1a] border border-white/10"
|
||||
onClick={({ key }) => onConfigChange('language', key as LanguageValue)}
|
||||
items={LanguageOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: <span className="text-gray-300">{option.label}</span>,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Duration dropdown menu */
|
||||
const durationMenu = (
|
||||
<Menu
|
||||
className="bg-[#1a1a1a] border border-white/10"
|
||||
onClick={({ key }) => onConfigChange('videoDuration', key as VideoDurationValue)}
|
||||
items={VideoDurationOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: <span className="text-gray-300">{option.label}</span>,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
const currentLanguage = LanguageOptions.find((option) => option.value === configOptions.language);
|
||||
const currentDuration = configOptions.videoDuration === 'unlimited' ? 'auto' : configOptions.videoDuration;
|
||||
|
||||
return (
|
||||
<div data-alt="config-panel" className="flex items-center gap-2">
|
||||
{/* Language selector */}
|
||||
<Dropdown overlay={languageMenu} trigger={['click']}>
|
||||
<button
|
||||
data-alt="config-language"
|
||||
className="h-8 px-2 rounded-full border border-white/20 bg-transparent text-gray-300 hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center gap-2 hover:text-cyan-400"
|
||||
>
|
||||
<GlobalOutlined className="text-base" />
|
||||
<span className="text-sm">{currentLanguage?.code}</span>
|
||||
<DownOutlined className="text-xs" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Auto script toggle */}
|
||||
<Tooltip title="AI Story Copilot" placement="top">
|
||||
<button
|
||||
data-alt="config-expansion-mode"
|
||||
className={`h-8 px-2 rounded-full border transition-all duration-200 flex items-center gap-2 text-sm ${
|
||||
configOptions.videoDuration === '8s'
|
||||
? 'opacity-40 cursor-not-allowed border-white/20 bg-transparent text-gray-400'
|
||||
: configOptions.expansion_mode
|
||||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400 hover:bg-cyan-400/20'
|
||||
: 'border-white/20 bg-transparent text-gray-300 hover:bg-white/5 hover:border-cyan-400/60 hover:text-cyan-400'
|
||||
}`}
|
||||
disabled={configOptions.videoDuration === '8s'}
|
||||
onClick={() => {
|
||||
if (configOptions.videoDuration !== '8s') {
|
||||
onConfigChange('expansion_mode', !configOptions.expansion_mode);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
<span>AutoScript</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Duration selector */}
|
||||
<Dropdown overlay={durationMenu} trigger={['click']}>
|
||||
<button
|
||||
data-alt="config-video-duration"
|
||||
className="h-8 px-2 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center gap-2 text-gray-300 hover:text-cyan-400"
|
||||
>
|
||||
<ClockCircleOutlined className="text-base" />
|
||||
<span className="text-sm capitalize">{currentDuration}</span>
|
||||
<DownOutlined className="text-xs" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Aspect ratio toggles */}
|
||||
<div data-alt="aspect-ratio-controls" className="h-8 px-2 py-1 flex items-center gap-1 rounded-full border border-white/20 hover:border-cyan-400/60">
|
||||
<button
|
||||
data-alt="portrait-button"
|
||||
className={`w-8 h-6 rounded-full transition-all duration-200 flex items-center justify-center ${
|
||||
configOptions.aspect_ratio === 'VIDEO_ASPECT_RATIO_PORTRAIT'
|
||||
? 'bg-white/10 text-cyan-400 shadow-sm'
|
||||
: 'bg-transparent text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
onClick={() => onConfigChange('aspect_ratio', 'VIDEO_ASPECT_RATIO_PORTRAIT')}
|
||||
>
|
||||
<RectangleVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
data-alt="landscape-button"
|
||||
className={`w-8 h-6 rounded-full transition-all duration-200 flex items-center justify-center ${
|
||||
configOptions.aspect_ratio === 'VIDEO_ASPECT_RATIO_LANDSCAPE'
|
||||
? 'bg-white/10 text-cyan-400 shadow-sm'
|
||||
: 'bg-transparent text-gray-400 hover:text-gray-300'
|
||||
}`}
|
||||
onClick={() => onConfigChange('aspect_ratio', 'VIDEO_ASPECT_RATIO_LANDSCAPE')}
|
||||
>
|
||||
<RectangleHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
77
components/pages/create-video/CreateInput/README.md
Normal file
77
components/pages/create-video/CreateInput/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# CreateInput Component
|
||||
|
||||
Video creation input form with configuration options.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
CreateInput/
|
||||
├── VideoCreationForm.tsx # Main form component
|
||||
├── ConfigPanel.tsx # Configuration panel with all options
|
||||
├── config-options.ts # Configuration options and types
|
||||
├── index.ts # Module exports
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### VideoCreationForm
|
||||
Main form component for video creation, includes:
|
||||
- Photo upload (character, scene, prop)
|
||||
- Text input area
|
||||
- Configuration options
|
||||
- Create button
|
||||
|
||||
### ConfigPanel
|
||||
Configuration panel with unified circular button style:
|
||||
- Language selector (14 languages)
|
||||
- Auto Script toggle (AI Story Copilot)
|
||||
- Duration selector (8s, 1min, 2min, auto)
|
||||
- Aspect ratio selector (landscape/portrait)
|
||||
|
||||
All buttons follow the same design pattern:
|
||||
- Circular buttons with `rounded-full`
|
||||
- Border: `border-white/20`
|
||||
- Hover: `hover:bg-white/5 hover:border-cyan-400/60`
|
||||
- Active state: `border-cyan-400 bg-cyan-400/10`
|
||||
- Cyan color theme for interactions
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Defined in `config-options.ts`:
|
||||
|
||||
```typescript
|
||||
interface ConfigOptions {
|
||||
language: LanguageValue; // Default: 'english'
|
||||
expansion_mode: boolean; // Default: false
|
||||
videoDuration: VideoDurationValue; // Default: 'unlimited'
|
||||
aspect_ratio: AspectRatioValue; // Default: 'VIDEO_ASPECT_RATIO_LANDSCAPE'
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { VideoCreationForm } from '@/components/pages/create-video/CreateInput';
|
||||
|
||||
export default function CreatePage() {
|
||||
return <VideoCreationForm />;
|
||||
}
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
All configuration buttons follow a unified design:
|
||||
- **Base style**: `rounded-full border border-white/20 bg-transparent`
|
||||
- **Hover**: `hover:bg-white/5 hover:border-cyan-400/60 hover:text-cyan-400`
|
||||
- **Active**: `border-cyan-400 bg-cyan-400/10 text-cyan-400`
|
||||
- **Transition**: `transition-all duration-200`
|
||||
|
||||
## Notes
|
||||
|
||||
- Configuration options are synchronized with ChatInputBox component
|
||||
- All text and labels are in English as per project guidelines
|
||||
- Uses Tailwind CSS 3.x for all styling
|
||||
- Uses Ant Design icons for consistency with existing UI
|
||||
- Responsive design with mobile/desktop support
|
||||
|
||||
193
components/pages/create-video/CreateInput/VideoCreationForm.tsx
Normal file
193
components/pages/create-video/CreateInput/VideoCreationForm.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { PhotoPreviewSection } from '../PhotoPreview';
|
||||
import type { PhotoItem, PhotoType } from '../PhotoPreview/types';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
CameraOutlined,
|
||||
BulbOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { ConfigPanel } from './ConfigPanel';
|
||||
import { defaultConfig } from './config-options';
|
||||
import type { ConfigOptions } from './config-options';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
|
||||
export default function VideoCreationForm() {
|
||||
const [photos, setPhotos] = useState<PhotoItem[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [configOptions, setConfigOptions] = useState<ConfigOptions>(defaultConfig);
|
||||
|
||||
const { isMobile, isDesktop } = useDeviceType();
|
||||
|
||||
const characterInputRef = useRef<HTMLInputElement>(null);
|
||||
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}`,
|
||||
}));
|
||||
|
||||
setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
/** Handle configuration change */
|
||||
const handleConfigChange = <K extends keyof ConfigOptions>(
|
||||
key: K,
|
||||
value: ConfigOptions[K]
|
||||
) => {
|
||||
setConfigOptions(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
/** Handle video creation */
|
||||
const handleCreate = () => {
|
||||
console.log({
|
||||
text: inputText,
|
||||
photos,
|
||||
config: configOptions,
|
||||
});
|
||||
// TODO: Implement video creation logic
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-alt="video-creation-form" className="w-full h-full flex flex-col">
|
||||
{/* Main Content Area with Border */}
|
||||
<div
|
||||
data-alt="content-container"
|
||||
className="flex-1 border border-white/10 rounded-3xl bg-gradient-to-br from-[#1a1a1a]/50 to-[#0a0a0a]/50 backdrop-blur-sm overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Photo Preview Section - Top */}
|
||||
{photos.length > 0 && (
|
||||
<div data-alt="photo-preview-wrapper" className="px-6 pt-6">
|
||||
<PhotoPreviewSection
|
||||
photos={photos}
|
||||
onDelete={handleDeletePhoto}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input Area - Middle */}
|
||||
<div data-alt="text-input-wrapper" className="flex-1 px-4 py-4">
|
||||
<textarea
|
||||
data-alt="main-text-input"
|
||||
className="w-full h-full bg-transparent text-gray-300 text-base placeholder-gray-500 resize-none outline-none border-none"
|
||||
placeholder="Share a topic, idea, or instructions with Video Agent to produce a full avatar video"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Panel - Bottom */}
|
||||
<div data-alt="control-panel" className="px-4 py-4 flex items-center justify-between gap-2">
|
||||
{/* Left Side - Upload and Options */}
|
||||
<div data-alt="left-controls" className="flex items-center gap-2">
|
||||
{/* Upload Button with Dropdown */}
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu className="bg-[#1a1a1a] border border-white/10">
|
||||
<Menu.Item
|
||||
key="character"
|
||||
icon={<UserOutlined className="text-cyan-400" />}
|
||||
onClick={() => characterInputRef.current?.click()}
|
||||
>
|
||||
<span className="text-gray-300">Character</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="scene"
|
||||
icon={<CameraOutlined className="text-cyan-400" />}
|
||||
onClick={() => sceneInputRef.current?.click()}
|
||||
>
|
||||
<span className="text-gray-300">Scene</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="prop"
|
||||
icon={<BulbOutlined className="text-cyan-400" />}
|
||||
onClick={() => propInputRef.current?.click()}
|
||||
>
|
||||
<span className="text-gray-300">Prop</span>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}
|
||||
trigger={['click']}
|
||||
>
|
||||
<button
|
||||
data-alt="upload-button"
|
||||
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
|
||||
>
|
||||
<PlusOutlined className="text-base font-bold" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={characterInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e, 'character')}
|
||||
/>
|
||||
<input
|
||||
ref={sceneInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e, 'scene')}
|
||||
/>
|
||||
<input
|
||||
ref={propInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e, 'prop')}
|
||||
/>
|
||||
|
||||
{/* Mention Button */}
|
||||
<button
|
||||
data-alt="mention-button"
|
||||
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
|
||||
>
|
||||
<span className="text-base font-bold">@</span>
|
||||
</button>
|
||||
|
||||
{/* Configuration Panel */}
|
||||
<ConfigPanel
|
||||
configOptions={configOptions}
|
||||
onConfigChange={handleConfigChange}
|
||||
isMobile={isMobile}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Create Button */}
|
||||
<button
|
||||
data-alt="create-button"
|
||||
className="w-8 h-8 rounded-full bg-black hover:bg-gray-900 border border-white/20 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-white shadow-lg hover:shadow-cyan-400/20"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<ArrowRightOutlined className="text-base font-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
51
components/pages/create-video/CreateInput/config-options.ts
Normal file
51
components/pages/create-video/CreateInput/config-options.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/** Language configuration options */
|
||||
export const LanguageOptions = [
|
||||
{ value: "english", label: "English", isVip: false, code: 'EN' },
|
||||
{ value: "chinese", label: "Chinese", isVip: false, code: 'ZH' },
|
||||
{ value: "japanese", label: "Japanese", isVip: false, code: 'JA' },
|
||||
{ value: "spanish", label: "Spanish", isVip: false, code: 'ES' },
|
||||
{ value: "portuguese", label: "Portuguese", isVip: false, code: 'PT' },
|
||||
{ value: "hindi", label: "Hindi", isVip: false, code: 'HI' },
|
||||
{ value: "korean", label: "Korean", isVip: false, code: 'KO' },
|
||||
{ value: "arabic", label: "Arabic", isVip: false, code: 'AR' },
|
||||
{ value: "russian", label: "Russian", isVip: false, code: 'RU' },
|
||||
{ value: "thai", label: "Thai", isVip: false, code: 'TH' },
|
||||
{ value: "french", label: "French", isVip: false, code: 'FR' },
|
||||
{ value: "german", label: "German", isVip: false, code: 'DE' },
|
||||
{ value: "vietnamese", label: "Vietnamese", isVip: false, code: 'VI' },
|
||||
{ value: "indonesian", label: "Indonesian", isVip: false, code: 'ID' }
|
||||
];
|
||||
|
||||
/** Video duration options */
|
||||
export const VideoDurationOptions = [
|
||||
{ value: "8s", label: "8s" },
|
||||
{ value: "1min", label: "1min" },
|
||||
{ value: "2min", label: "2min" },
|
||||
{ value: "unlimited", label: "auto" },
|
||||
];
|
||||
|
||||
/** Language type */
|
||||
export type LanguageValue = typeof LanguageOptions[number]['value'];
|
||||
|
||||
/** Video duration type */
|
||||
export type VideoDurationValue = typeof VideoDurationOptions[number]['value'];
|
||||
|
||||
/** Aspect ratio type */
|
||||
export type AspectRatioValue = 'VIDEO_ASPECT_RATIO_LANDSCAPE' | 'VIDEO_ASPECT_RATIO_PORTRAIT';
|
||||
|
||||
/** Configuration options interface */
|
||||
export interface ConfigOptions {
|
||||
language: LanguageValue;
|
||||
expansion_mode: boolean;
|
||||
videoDuration: VideoDurationValue;
|
||||
aspect_ratio: AspectRatioValue;
|
||||
}
|
||||
|
||||
/** Default configuration */
|
||||
export const defaultConfig: ConfigOptions = {
|
||||
language: 'english',
|
||||
expansion_mode: false,
|
||||
videoDuration: 'unlimited',
|
||||
aspect_ratio: 'VIDEO_ASPECT_RATIO_LANDSCAPE',
|
||||
};
|
||||
|
||||
4
components/pages/create-video/CreateInput/index.ts
Normal file
4
components/pages/create-video/CreateInput/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as VideoCreationForm } from './VideoCreationForm';
|
||||
export { ConfigPanel } from './ConfigPanel';
|
||||
export * from './config-options';
|
||||
|
||||
17
components/pages/create-video/CreateVideo.tsx
Normal file
17
components/pages/create-video/CreateVideo.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { VideoCreationForm } from './CreateInput';
|
||||
|
||||
export default function CreateVideo() {
|
||||
return (
|
||||
<div data-alt="create-video-page" className="w-full">
|
||||
<div className='p-1 text-center text-2xl font-bold tracking-tight sm:text-3xl'>Transform any idea into a compelling video</div>
|
||||
<div className='mx-auto w-full max-w-[600px] px-3 py-2 text-center text-xs text-gray-400 sm:px-16 sm:text-sm'>Generate professional videos from simple prompts. Browse community creations for inspiration, or start fresh with your own vision</div>
|
||||
<div className='py-2'>
|
||||
<div className='space-y-4 mx-auto w-full max-w-[900px] px-3 sm:px-16'>
|
||||
<VideoCreationForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import { CloseOutlined, 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
|
||||
* @param photos - Array of photos with type information
|
||||
* @param onDelete - Callback when a photo is deleted
|
||||
* @param className - Additional CSS classes
|
||||
*/
|
||||
export default function PhotoPreviewSection({
|
||||
photos = [],
|
||||
onDelete,
|
||||
className = '',
|
||||
}: PhotoPreviewSectionProps) {
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
/**
|
||||
* Get icon for photo type
|
||||
*/
|
||||
const getTypeIcon = (type: PhotoType) => {
|
||||
switch (type) {
|
||||
case 'character':
|
||||
return <UserOutlined className="text-sm" />;
|
||||
case 'scene':
|
||||
return <CameraOutlined className="text-sm" />;
|
||||
case 'prop':
|
||||
return <BulbOutlined className="text-sm" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get label for photo type
|
||||
*/
|
||||
const getTypeLabel = (type: PhotoType) => {
|
||||
switch (type) {
|
||||
case 'character':
|
||||
return 'Character';
|
||||
case 'scene':
|
||||
return 'Scene';
|
||||
case 'prop':
|
||||
return 'Prop';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle photo preview
|
||||
*/
|
||||
const handlePreview = (url: string) => {
|
||||
setPreviewImage(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle photo deletion
|
||||
*/
|
||||
const handleDelete = (index: number) => {
|
||||
onDelete?.(index);
|
||||
};
|
||||
|
||||
/** Don't render if no photos */
|
||||
if (photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-alt="photo-preview-section" className={`w-full ${className}`}>
|
||||
{/* Photo List Container with Horizontal Scroll */}
|
||||
<div
|
||||
data-alt="photo-list-container"
|
||||
className="flex gap-2 overflow-x-auto overflow-y-hidden photo-list-scrollbar pb-1"
|
||||
>
|
||||
{photos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id || `photo-${index}`}
|
||||
data-alt="photo-item"
|
||||
className="relative flex-shrink-0 w-16 h-16 rounded-md overflow-visible border border-white/10 hover:border-cyan-400/60 transition-all duration-200 cursor-pointer group bg-black/20"
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
{/* Photo Image */}
|
||||
<div
|
||||
className="w-full h-full rounded-md overflow-hidden"
|
||||
onClick={() => handlePreview(photo.url)}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={`${getTypeLabel(photo.type)} ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Icon - Bottom Left Corner */}
|
||||
<div
|
||||
data-alt="type-icon"
|
||||
className="absolute bottom-1 left-1 w-5 h-5 bg-black/60 backdrop-blur-sm text-cyan-400 rounded-sm flex items-center justify-center z-[5]"
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Preview Modal - Using absolute positioning to avoid layout space */}
|
||||
<div style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden', opacity: 0, pointerEvents: 'none' }}>
|
||||
<Image
|
||||
width={0}
|
||||
height={0}
|
||||
src={previewImage}
|
||||
preview={{
|
||||
visible: previewVisible,
|
||||
src: previewImage,
|
||||
onVisibleChange: (visible) => {
|
||||
setPreviewVisible(visible);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
113
components/pages/create-video/PhotoPreview/README.md
Normal file
113
components/pages/create-video/PhotoPreview/README.md
Normal file
@ -0,0 +1,113 @@
|
||||
# PhotoPreviewSection Component
|
||||
|
||||
Compact photo preview component with type indicators for characters, scenes, and props.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Type Indicators** - Icons in bottom-left corner show photo type
|
||||
✅ **Compact Design** - 64x64px photos with horizontal scroll
|
||||
✅ **Smart Visibility** - Auto-hides when no photos
|
||||
✅ **Preview Support** - Click to view full-size image
|
||||
✅ **Delete Functionality** - X button appears on hover
|
||||
✅ **Dark Theme** - Fully styled for dark UI
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { PhotoPreviewSection } from './PhotoPreview';
|
||||
import type { PhotoItem } from './PhotoPreview';
|
||||
|
||||
function MyComponent() {
|
||||
const [photos, setPhotos] = useState<PhotoItem[]>([
|
||||
{ url: 'photo1.jpg', type: 'character', id: '1' },
|
||||
{ url: 'photo2.jpg', type: 'scene', id: '2' },
|
||||
{ url: 'photo3.jpg', type: 'prop', id: '3' },
|
||||
]);
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
setPhotos(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<PhotoPreviewSection
|
||||
photos={photos}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `photos` | `PhotoItem[]` | `[]` | Array of photos with type information |
|
||||
| `onDelete` | `(index: number) => void` | optional | Callback when a photo is deleted |
|
||||
| `className` | `string` | `''` | Additional CSS classes |
|
||||
|
||||
## Photo Types
|
||||
|
||||
### PhotoItem Interface
|
||||
|
||||
```typescript
|
||||
interface PhotoItem {
|
||||
url: string; // Photo URL
|
||||
type: PhotoType; // 'character' | 'scene' | 'prop'
|
||||
id?: string; // Optional unique identifier
|
||||
}
|
||||
```
|
||||
|
||||
### Type Icons
|
||||
|
||||
- **Character** (👤): `UserOutlined` - Cyan icon in bottom-left
|
||||
- **Scene** (📷): `CameraOutlined` - Cyan icon in bottom-left
|
||||
- **Prop** (💡): `BulbOutlined` - Cyan icon in bottom-left
|
||||
|
||||
## Design Specifications
|
||||
|
||||
- **Photo Size**: 64x64px (w-16 h-16)
|
||||
- **Gap**: 8px between items
|
||||
- **Border Radius**: 6px (rounded-md)
|
||||
- **Type Icon**: 20x20px square, bottom-left corner with backdrop blur
|
||||
- **Delete Button**: Circular gray button, top-right corner (hover only)
|
||||
- **Scrollbar**: 4px height, appears on hover
|
||||
|
||||
## Interactions
|
||||
|
||||
- **Click Photo**: Opens full-screen preview modal
|
||||
- **Hover Photo**: Shows delete button at top-right corner
|
||||
- **Click Delete**: Triggers `onDelete` callback with photo index
|
||||
- **Type Icon**: Always visible in bottom-left corner
|
||||
|
||||
## Visual Layout
|
||||
|
||||
```
|
||||
┌───────────────────────────────────┐
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ [img]│ │ [img]│ │ [img]│ │
|
||||
│ │ 👤 │ │ 📷 │ │ 💡 │ │
|
||||
│ └──────┘ └──────┘ └──────┘ │
|
||||
│ Char Scene Prop │
|
||||
└───────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Top-right: Delete button (X) - appears on hover
|
||||
- Bottom-left: Type icon - always visible
|
||||
|
||||
## Styling
|
||||
|
||||
The component uses Tailwind CSS with custom dark theme colors:
|
||||
|
||||
- **Primary Color**: `cyan-400` for type icons
|
||||
- **Text Colors**: White with varying opacity
|
||||
- **Borders**: White with 10% opacity, hover to cyan-400 with 60% opacity
|
||||
- **Backgrounds**: Black/White with low opacity + backdrop blur
|
||||
- **Delete Button**: Gray (`#636364`) with 80% opacity
|
||||
|
||||
## Notes
|
||||
|
||||
- Component returns `null` when `photos` array is empty
|
||||
- Upload functionality should be implemented externally
|
||||
- Each photo should have a unique `id` for optimal React key handling
|
||||
- Preview uses antd's Image component for full-screen viewing
|
||||
6
components/pages/create-video/PhotoPreview/index.ts
Normal file
6
components/pages/create-video/PhotoPreview/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/** Export PhotoPreviewSection component */
|
||||
export { default as PhotoPreviewSection } from './PhotoPreviewSection';
|
||||
|
||||
/** Export types */
|
||||
export type { PhotoPreviewSectionProps, PhotoItem, PhotoType } from './types';
|
||||
|
||||
33
components/pages/create-video/PhotoPreview/styles.css
Normal file
33
components/pages/create-video/PhotoPreview/styles.css
Normal file
@ -0,0 +1,33 @@
|
||||
/** Custom styles for Photo Preview Section */
|
||||
|
||||
/** Override antd Upload component styles for compact mode */
|
||||
.photo-upload-button-compact .ant-upload.ant-upload-select {
|
||||
width: 4rem !important;
|
||||
height: 4rem !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.photo-upload-button-compact .ant-upload-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/** Custom scrollbar for photo list */
|
||||
.photo-list-scrollbar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.photo-list-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.photo-list-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.photo-list-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
25
components/pages/create-video/PhotoPreview/types.ts
Normal file
25
components/pages/create-video/PhotoPreview/types.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Photo type enum */
|
||||
export type PhotoType = 'character' | 'scene' | 'prop';
|
||||
|
||||
/** Photo item with type information */
|
||||
export interface PhotoItem {
|
||||
/** Photo URL */
|
||||
url: string;
|
||||
/** Photo type: character, scene, or prop */
|
||||
type: PhotoType;
|
||||
/** Optional unique identifier */
|
||||
id?: 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;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user