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 {
|
interface NavigationItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -17,6 +17,7 @@ export const navigationItems: Navigations[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||||
{ name: 'Share', href: '/share', icon: Gift },
|
{ 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