Kling video generation: - Full T2V, I2V, extend, and lip sync workflows via Kling API - V3, V2.6, V2.5 Turbo, V2.1 Master, V1.6 model support - Resolution selector (720p std / 1080p pro) with model constraints - Native audio toggle with dialogue input for Kling - Video ID tracking for extend and lip sync chains - Camera control presets (pan, tilt, arc) Prompt optimizer rework: - Intent-preserving refinement (camera, action, mood are sacred) - Mode-aware: T2V adds subject/environment detail, I2V describes only motion - Reference images analyzed for content, not re-described - Platform-specific quality anchors woven into positive prompt - Negative prompts removed from optimizer (positive-only approach) - 15-60 word target for concise, effective prompts Backend fixes: - Gemini responseModalities: ['TEXT', 'IMAGE'] for Flash model compatibility - Veo first-frame resize to exact target dimensions (prevents letterboxing) - Session directory re-creation in saveImage (auto-cleanup race condition) - Kling API error logging with HTTP codes and payload details - Lip sync endpoint updated to /v1/videos/lip-sync with video_id Frontend stability: - Tab persistence via CSS hidden (generation survives tab switches) - Project switch protection (confirm dialog when generation in progress) - Retina thumbnails (480px/q0.8) for library grid — prevents OOM crashes - Thumbnail backfill migration for existing project items - Project items refresh on tab visibility and after save - 1:1 aspect ratio container for Kling videos - Expanded video view matches library modal behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1640 lines
74 KiB
JavaScript
1640 lines
74 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { FolderOpen, Plus, Image, Video, Trash2, Edit2, Check, X, Download, Clock, Search, Grid, List, Loader2, AlertCircle, Play, RefreshCw, Layers, CheckSquare, Square, Upload, Wand2, Database, ArrowRightLeft, Maximize2, Minimize2, Copy } from 'lucide-react';
|
|
import useProjects from '../hooks/useProjects';
|
|
import useCustomPresets from '../hooks/useCustomPresets';
|
|
import VideoPlayer from './VideoPlayer';
|
|
import StoryboardEditor from './StoryboardEditor';
|
|
|
|
const ProjectsTab = ({ onProjectSelect, activeProjectId, onRerunVideo, onEditInImageGen }) => {
|
|
// Helper function to download items (handles both base64 and URL data)
|
|
const downloadItem = async (item) => {
|
|
try {
|
|
const isUrl = item.data && (item.data.startsWith('http') || item.data.startsWith('/'));
|
|
let blob;
|
|
let filename = `lux-studio-${item.id}.${(item.mimeType || 'image/png').split('/')[1]}`;
|
|
|
|
if (isUrl) {
|
|
// For URLs, fetch the file and download as blob
|
|
const response = await fetch(item.data);
|
|
blob = await response.blob();
|
|
} else {
|
|
// For base64 data, convert to Blob
|
|
const base64Data = item.data.startsWith('data:')
|
|
? item.data.split(',')[1]
|
|
: item.data;
|
|
|
|
const byteCharacters = atob(base64Data);
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
blob = new Blob([byteArray], { type: item.mimeType || 'image/png' });
|
|
}
|
|
|
|
// Create download link
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
// Fallback to data URL method
|
|
const link = document.createElement('a');
|
|
link.href = item.data.startsWith('data:') ? item.data : `data:${item.mimeType};base64,${item.data}`;
|
|
link.download = `lux-studio-${item.id}.${(item.mimeType || 'image/png').split('/')[1]}`;
|
|
link.click();
|
|
}
|
|
};
|
|
|
|
const {
|
|
projects,
|
|
isLoading,
|
|
isReady,
|
|
error: dbError,
|
|
createProject,
|
|
deleteProject,
|
|
renameProject,
|
|
getProjectItems,
|
|
removeItemFromProject,
|
|
moveItemToProject,
|
|
addItemToProject,
|
|
exportProject,
|
|
createStoryboard,
|
|
getStoryboards,
|
|
getStoryboard,
|
|
updateStoryboard,
|
|
deleteStoryboard
|
|
} = useProjects();
|
|
|
|
const { presets: customPresets } = useCustomPresets();
|
|
|
|
// Resolve preset display name (handles both built-in and custom:uuid values)
|
|
const getPresetDisplayName = (applicationValue) => {
|
|
if (!applicationValue) return null;
|
|
if (applicationValue.startsWith('custom:')) {
|
|
const preset = customPresets.find(p => p.value === applicationValue);
|
|
return preset ? preset.name : 'Custom Preset';
|
|
}
|
|
return applicationValue;
|
|
};
|
|
|
|
const [selectedProject, setSelectedProject] = useState(null);
|
|
|
|
// Sync selectedProject with activeProjectId when projects load
|
|
useEffect(() => {
|
|
if (activeProjectId && projects.length > 0 && !selectedProject) {
|
|
const project = projects.find(p => p.id === activeProjectId);
|
|
if (project) {
|
|
setSelectedProject(project);
|
|
}
|
|
}
|
|
}, [activeProjectId, projects]);
|
|
|
|
// Handle project selection - notify parent
|
|
const handleSelectProject = (project) => {
|
|
setSelectedProject(project);
|
|
if (onProjectSelect) {
|
|
onProjectSelect(project.id, project.name);
|
|
}
|
|
};
|
|
const [selectedProjectItems, setSelectedProjectItems] = useState([]);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [newProjectName, setNewProjectName] = useState('');
|
|
const [editingId, setEditingId] = useState(null);
|
|
const [editName, setEditName] = useState('');
|
|
const [viewMode, setViewMode] = useState('grid');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [previewItem, setPreviewItem] = useState(null);
|
|
const [movingItemId, setMovingItemId] = useState(null);
|
|
const [previewExpanded, setPreviewExpanded] = useState(false);
|
|
|
|
// Sub-tab state: 'library' or 'storyboards'
|
|
const [activeSubTab, setActiveSubTab] = useState('library');
|
|
|
|
// Selection mode for creating storyboards
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
const [selectedImageIds, setSelectedImageIds] = useState([]);
|
|
|
|
// Storyboards state
|
|
const [storyboards, setStoryboards] = useState([]);
|
|
const [isCreatingStoryboard, setIsCreatingStoryboard] = useState(false);
|
|
const [newStoryboardName, setNewStoryboardName] = useState('');
|
|
const [activeStoryboard, setActiveStoryboard] = useState(null);
|
|
const [editingStoryboardId, setEditingStoryboardId] = useState(null); // For editing existing storyboard frames
|
|
|
|
// File upload ref
|
|
const fileInputRef = useRef(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// Import from backend state
|
|
const [copiedPrompt, setCopiedPrompt] = useState(false);
|
|
const [showImportModal, setShowImportModal] = useState(false);
|
|
const [availableSessions, setAvailableSessions] = useState([]);
|
|
const [selectedFiles, setSelectedFiles] = useState([]);
|
|
const [importing, setImporting] = useState(false);
|
|
const [importProgress, setImportProgress] = useState({ current: 0, total: 0 });
|
|
|
|
// Load items and storyboards when project is selected
|
|
useEffect(() => {
|
|
if (selectedProject) {
|
|
loadProjectItems(selectedProject.id);
|
|
loadStoryboards(selectedProject.id);
|
|
// Clear selection and active storyboard when switching projects
|
|
setSelectedImageIds([]);
|
|
setIsSelecting(false);
|
|
setActiveStoryboard(null);
|
|
}
|
|
}, [selectedProject]);
|
|
|
|
const loadProjectItems = async (projectId) => {
|
|
try {
|
|
const items = await getProjectItems(projectId);
|
|
setSelectedProjectItems(items);
|
|
} catch (err) {
|
|
console.error('Failed to load project items:', err);
|
|
setSelectedProjectItems([]);
|
|
}
|
|
};
|
|
|
|
const loadStoryboards = async (projectId) => {
|
|
try {
|
|
const boards = await getStoryboards(projectId);
|
|
setStoryboards(boards);
|
|
} catch (err) {
|
|
console.error('Failed to load storyboards:', err);
|
|
setStoryboards([]);
|
|
}
|
|
};
|
|
|
|
// Handle image upload
|
|
const handleImageUpload = async (event) => {
|
|
const files = event.target.files;
|
|
if (!files || files.length === 0 || !selectedProject) return;
|
|
|
|
setIsUploading(true);
|
|
setError('');
|
|
|
|
try {
|
|
for (const file of files) {
|
|
// Only accept images
|
|
if (!file.type.startsWith('image/')) {
|
|
continue;
|
|
}
|
|
|
|
// Read file as base64
|
|
const base64 = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result;
|
|
// Remove data URL prefix to get pure base64
|
|
const base64Data = result.split(',')[1];
|
|
resolve(base64Data);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// Add to project
|
|
await addItemToProject(selectedProject.id, {
|
|
type: 'image',
|
|
prompt: `Uploaded: ${file.name}`,
|
|
data: base64,
|
|
mimeType: file.type,
|
|
thumbnail: base64
|
|
});
|
|
}
|
|
|
|
// Reload items
|
|
await loadProjectItems(selectedProject.id);
|
|
} catch (err) {
|
|
setError('Failed to upload image: ' + err.message);
|
|
console.error('Upload error:', err);
|
|
} finally {
|
|
setIsUploading(false);
|
|
// Reset file input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
// API URL helper for import endpoints
|
|
const getApiUrl = (endpoint) => {
|
|
if (import.meta.env.DEV) {
|
|
return `/api/${endpoint}`;
|
|
}
|
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5015';
|
|
return `${apiUrl}/${endpoint}`;
|
|
};
|
|
|
|
// Fetch available backend session files
|
|
const fetchAvailableSessions = async () => {
|
|
try {
|
|
const response = await fetch(getApiUrl('list_session_files.php'));
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setAvailableSessions(data.sessions);
|
|
return data.sessions;
|
|
} else {
|
|
throw new Error(data.error || 'Failed to fetch sessions');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching sessions:', err);
|
|
setError('Failed to load backend files: ' + err.message);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Handle opening import modal
|
|
const handleOpenImportModal = async () => {
|
|
if (!selectedProject) {
|
|
setError('Please select a project first');
|
|
return;
|
|
}
|
|
|
|
setShowImportModal(true);
|
|
setSelectedFiles([]);
|
|
await fetchAvailableSessions();
|
|
};
|
|
|
|
// Toggle file selection
|
|
const toggleFileSelection = (sessionId, fileType, filename) => {
|
|
const fileKey = `${sessionId}:${fileType}:${filename}`;
|
|
setSelectedFiles(prev => {
|
|
if (prev.includes(fileKey)) {
|
|
return prev.filter(f => f !== fileKey);
|
|
} else {
|
|
return [...prev, fileKey];
|
|
}
|
|
});
|
|
};
|
|
|
|
// Import selected files
|
|
const handleImportFiles = async () => {
|
|
if (selectedFiles.length === 0 || !selectedProject) return;
|
|
|
|
setImporting(true);
|
|
setError('');
|
|
setImportProgress({ current: 0, total: selectedFiles.length });
|
|
|
|
try {
|
|
for (let i = 0; i < selectedFiles.length; i++) {
|
|
const fileKey = selectedFiles[i];
|
|
const [sessionId, fileType, filename] = fileKey.split(':');
|
|
|
|
setImportProgress({ current: i + 1, total: selectedFiles.length });
|
|
|
|
// Fetch file from backend
|
|
const response = await fetch(
|
|
getApiUrl(`get_session_file.php?session_id=${encodeURIComponent(sessionId)}&file_type=${fileType}&filename=${encodeURIComponent(filename)}`)
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
console.error(`Failed to fetch ${filename}:`, data.error);
|
|
continue;
|
|
}
|
|
|
|
// Add to project
|
|
if (fileType === 'image') {
|
|
await addItemToProject(selectedProject.id, {
|
|
type: 'image',
|
|
prompt: `Imported: ${filename}`,
|
|
settings: {},
|
|
data: data.data, // Base64
|
|
mimeType: data.mime_type,
|
|
thumbnail: data.data
|
|
});
|
|
} else if (fileType === 'video') {
|
|
// Skip videos for now - they need to be in generated_videos folder
|
|
// TODO: Implement proper video import (copy to generated_videos or create session streaming endpoint)
|
|
console.warn(`Skipping video import for ${filename} - not yet supported`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Reload items
|
|
await loadProjectItems(selectedProject.id);
|
|
|
|
// Close modal
|
|
setShowImportModal(false);
|
|
setSelectedFiles([]);
|
|
setAvailableSessions([]);
|
|
|
|
alert(`Successfully imported ${selectedFiles.length} file(s)`);
|
|
} catch (err) {
|
|
setError('Import failed: ' + err.message);
|
|
console.error('Import error:', err);
|
|
} finally {
|
|
setImporting(false);
|
|
setImportProgress({ current: 0, total: 0 });
|
|
}
|
|
};
|
|
|
|
// Open a storyboard for editing
|
|
const handleOpenStoryboard = async (storyboardId) => {
|
|
try {
|
|
const board = await getStoryboard(storyboardId);
|
|
setActiveStoryboard(board);
|
|
} catch (err) {
|
|
console.error('Failed to load storyboard:', err);
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle storyboard update from editor
|
|
const handleUpdateStoryboard = async (storyboardId, updates) => {
|
|
try {
|
|
const updated = await updateStoryboard(storyboardId, updates);
|
|
setActiveStoryboard(updated);
|
|
await loadStoryboards(selectedProject.id);
|
|
return updated;
|
|
} catch (err) {
|
|
setError(err.message);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// Handle storyboard deletion from editor
|
|
const handleDeleteStoryboard = async (storyboardId) => {
|
|
try {
|
|
await deleteStoryboard(storyboardId);
|
|
setActiveStoryboard(null);
|
|
await loadStoryboards(selectedProject.id);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// Handle generate video from storyboard frame
|
|
const handleGenerateVideoFromFrame = (videoData) => {
|
|
if (onRerunVideo) {
|
|
onRerunVideo(videoData);
|
|
}
|
|
};
|
|
|
|
// Toggle image selection
|
|
const toggleImageSelection = (imageId) => {
|
|
setSelectedImageIds(prev =>
|
|
prev.includes(imageId)
|
|
? prev.filter(id => id !== imageId)
|
|
: [...prev, imageId]
|
|
);
|
|
};
|
|
|
|
// Create storyboard from selected images
|
|
const handleCreateStoryboard = async () => {
|
|
if (!newStoryboardName.trim() || selectedImageIds.length === 0) return;
|
|
|
|
try {
|
|
setError('');
|
|
await createStoryboard(selectedProject.id, newStoryboardName.trim(), selectedImageIds);
|
|
setNewStoryboardName('');
|
|
setIsCreatingStoryboard(false);
|
|
setSelectedImageIds([]);
|
|
setIsSelecting(false);
|
|
await loadStoryboards(selectedProject.id);
|
|
setActiveSubTab('storyboards');
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Edit storyboard frames - go to library with current frames pre-selected
|
|
const handleEditStoryboardFrames = (storyboardId, currentFrameIds) => {
|
|
setEditingStoryboardId(storyboardId);
|
|
setSelectedImageIds(currentFrameIds);
|
|
setIsSelecting(true);
|
|
setActiveSubTab('library');
|
|
setActiveStoryboard(null);
|
|
};
|
|
|
|
// Update storyboard with new frame selection
|
|
const handleUpdateStoryboardFrames = async () => {
|
|
if (!editingStoryboardId || selectedImageIds.length === 0) return;
|
|
|
|
try {
|
|
setError('');
|
|
// Get current storyboard to preserve annotations where possible
|
|
const currentStoryboard = await getStoryboard(editingStoryboardId);
|
|
const existingFrames = currentStoryboard?.frames || [];
|
|
|
|
// Build new frames array, preserving annotations for existing frames
|
|
const newFrames = selectedImageIds.map((imageId, index) => {
|
|
const existingFrame = existingFrames.find(f => f.imageId === imageId);
|
|
return {
|
|
imageId,
|
|
order: index,
|
|
annotation: existingFrame?.annotation || ''
|
|
};
|
|
});
|
|
|
|
await updateStoryboard(editingStoryboardId, { frames: newFrames });
|
|
|
|
// Reload and go back to the storyboard
|
|
await loadStoryboards(selectedProject.id);
|
|
const updatedBoard = await getStoryboard(editingStoryboardId);
|
|
setActiveStoryboard(updatedBoard);
|
|
setEditingStoryboardId(null);
|
|
setSelectedImageIds([]);
|
|
setIsSelecting(false);
|
|
setActiveSubTab('storyboards');
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle create project
|
|
const handleCreateProject = async () => {
|
|
if (!newProjectName.trim()) return;
|
|
|
|
try {
|
|
setError('');
|
|
const newProject = await createProject(newProjectName.trim());
|
|
setNewProjectName('');
|
|
setIsCreating(false);
|
|
setSelectedProject(newProject);
|
|
// Notify parent of new project selection
|
|
if (onProjectSelect) {
|
|
onProjectSelect(newProject.id, newProject.name);
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle delete project
|
|
const handleDeleteProject = async (projectId) => {
|
|
if (!confirm('Are you sure you want to delete this project and all its items?')) return;
|
|
|
|
try {
|
|
setError('');
|
|
await deleteProject(projectId);
|
|
if (selectedProject?.id === projectId) {
|
|
setSelectedProject(null);
|
|
setSelectedProjectItems([]);
|
|
// Clear parent state if deleted project was active
|
|
if (onProjectSelect) {
|
|
onProjectSelect(null, null);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle rename project
|
|
const handleRenameProject = async (projectId) => {
|
|
if (!editName.trim()) return;
|
|
|
|
try {
|
|
setError('');
|
|
const updated = await renameProject(projectId, editName.trim());
|
|
setEditingId(null);
|
|
setEditName('');
|
|
if (selectedProject?.id === projectId) {
|
|
setSelectedProject(updated);
|
|
// Notify parent of name change
|
|
if (onProjectSelect) {
|
|
onProjectSelect(updated.id, updated.name);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle delete item
|
|
const handleDeleteItem = async (itemId) => {
|
|
if (!confirm('Delete this item?')) return;
|
|
|
|
try {
|
|
setError('');
|
|
await removeItemFromProject(selectedProject.id, itemId);
|
|
setSelectedProjectItems(prev => prev.filter(i => i.id !== itemId));
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Handle export project
|
|
const handleExportProject = async (projectId) => {
|
|
try {
|
|
setError('');
|
|
const data = await exportProject(projectId);
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${data.project.name.replace(/\s+/g, '_')}_export.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
|
|
// Filter projects by search
|
|
const filteredProjects = projects.filter(p =>
|
|
p.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
// Format date
|
|
const formatDate = (timestamp) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
|
|
});
|
|
};
|
|
|
|
// Count items by type
|
|
const countItemsByType = (items, type) => items.filter(i => i.type === type).length;
|
|
|
|
// Show loading state
|
|
if (!isReady || isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[60vh]">
|
|
<div className="text-center">
|
|
<Loader2 className="w-8 h-8 animate-spin text-cinema-gold mx-auto mb-4" />
|
|
<p className="text-slate-400">Loading projects...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
{/* Left Sidebar: Project List */}
|
|
<div className="lg:col-span-4">
|
|
<div className="bg-slate-925 rounded p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-normal text-slate-200 flex items-center space-x-2">
|
|
<FolderOpen className="w-5 h-5 text-cinema-gold" />
|
|
<span>Projects</span>
|
|
</h2>
|
|
<button
|
|
onClick={() => setIsCreating(true)}
|
|
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded transition-all"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{(error || dbError) && (
|
|
<div className="flex items-center gap-2 p-3 bg-red-950/30 border border-red-900/50 rounded text-red-400 text-sm">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
<span>{error || dbError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search projects..."
|
|
className="w-full pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Create New Project */}
|
|
{isCreating && (
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newProjectName}
|
|
onChange={(e) => setNewProjectName(e.target.value)}
|
|
placeholder="Project name..."
|
|
className="flex-1 px-3 py-2 bg-slate-800 border border-cinema-gold rounded text-slate-200 placeholder-slate-500 focus:outline-none text-sm"
|
|
autoFocus
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateProject()}
|
|
/>
|
|
<button
|
|
onClick={handleCreateProject}
|
|
className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsCreating(false); setNewProjectName(''); }}
|
|
className="p-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Project List */}
|
|
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
|
{filteredProjects.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-500">
|
|
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
|
<p>{projects.length === 0 ? 'No projects yet' : 'No matching projects'}</p>
|
|
<p className="text-xs mt-1">
|
|
{projects.length === 0 ? 'Create one to start organizing' : 'Try a different search'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredProjects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
onClick={() => handleSelectProject(project)}
|
|
className={`group p-4 rounded cursor-pointer transition-all ${
|
|
selectedProject?.id === project.id
|
|
? 'bg-cinema-gold/20 border border-cinema-gold'
|
|
: 'bg-slate-800 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
{editingId === project.id ? (
|
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
className="flex-1 px-2 py-1 bg-slate-800 border border-cinema-gold rounded text-slate-200 text-sm"
|
|
autoFocus
|
|
onKeyDown={(e) => e.key === 'Enter' && handleRenameProject(project.id)}
|
|
/>
|
|
<button
|
|
onClick={() => handleRenameProject(project.id)}
|
|
className="p-1 bg-green-600 hover:bg-green-500 text-white rounded"
|
|
>
|
|
<Check className="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditingId(null); setEditName(''); }}
|
|
className="p-1 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-normal text-slate-200 truncate">{project.name}</h3>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingId(project.id);
|
|
setEditName(project.name);
|
|
}}
|
|
className="p-1 hover:bg-slate-700 rounded"
|
|
>
|
|
<Edit2 className="w-3 h-3 text-slate-400" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteProject(project.id);
|
|
}}
|
|
className="p-1 hover:bg-red-900/50 rounded"
|
|
>
|
|
<Trash2 className="w-3 h-3 text-red-400" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2 text-xs font-mono text-slate-500">
|
|
<span className="flex items-center gap-1 ml-auto">
|
|
<Clock className="w-3 h-3" />
|
|
{formatDate(project.updatedAt)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel: Project Contents */}
|
|
<div className="lg:col-span-8">
|
|
<div className="bg-slate-925 rounded p-6 min-h-[70vh]">
|
|
{selectedProject ? (
|
|
<div className="space-y-4">
|
|
{/* Project Header */}
|
|
<div className="flex items-center justify-between pb-4">
|
|
<div>
|
|
<h2 className="text-xl font-normal text-slate-200">{selectedProject.name}</h2>
|
|
<p className="text-xs font-mono text-slate-500 mt-1">
|
|
Created {formatDate(selectedProject.createdAt)} · {selectedProjectItems.length} items
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleExportProject(selectedProject.id)}
|
|
className="group relative p-2 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span className="absolute bottom-full right-0 mb-2 px-2 py-1 text-xs text-slate-200 bg-slate-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
Export Project Backup (JSON)
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sub-tabs: Library | Storyboards */}
|
|
<div className="flex items-center gap-4 pb-2">
|
|
<button
|
|
onClick={() => { setActiveSubTab('library'); setIsSelecting(false); setSelectedImageIds([]); }}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
|
|
activeSubTab === 'library'
|
|
? 'bg-cinema-gold/20 text-cinema-gold'
|
|
: 'text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Image className="w-4 h-4" />
|
|
<span>Library</span>
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveSubTab('storyboards'); setIsSelecting(false); setSelectedImageIds([]); }}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded transition-all ${
|
|
activeSubTab === 'storyboards'
|
|
? 'bg-cinema-gold/20 text-cinema-gold'
|
|
: 'text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
<span>Storyboards</span>
|
|
{storyboards.length > 0 && (
|
|
<span className="text-xs bg-slate-700 px-1.5 py-0.5 rounded">{storyboards.length}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Library Controls (only show when Library tab is active) */}
|
|
{activeSubTab === 'library' && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{/* Selection mode toggle */}
|
|
<button
|
|
onClick={() => {
|
|
setIsSelecting(!isSelecting);
|
|
if (isSelecting) setSelectedImageIds([]);
|
|
}}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-all ${
|
|
isSelecting
|
|
? 'bg-cinema-gold text-slate-950'
|
|
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
{isSelecting ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
|
{isSelecting ? `${selectedImageIds.length} selected` : 'Select'}
|
|
</button>
|
|
|
|
{/* Create/Update Storyboard button (when images are selected) */}
|
|
{isSelecting && selectedImageIds.length > 0 && (
|
|
editingStoryboardId ? (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleUpdateStoryboardFrames}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
Update Frames
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setEditingStoryboardId(null);
|
|
setSelectedImageIds([]);
|
|
setIsSelecting(false);
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm transition-all"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setIsCreatingStoryboard(true)}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
Create Storyboard
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Upload and View mode toggles */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Upload button */}
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploading}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded text-sm transition-all disabled:opacity-50"
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4" />
|
|
)}
|
|
{isUploading ? 'Uploading...' : 'Upload'}
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleImageUpload}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Import from Backend button */}
|
|
<button
|
|
onClick={handleOpenImportModal}
|
|
disabled={importing}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-slate-200 rounded text-sm transition-all disabled:opacity-50"
|
|
>
|
|
{importing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Database className="w-4 h-4" />
|
|
)}
|
|
{importing ? `Importing ${importProgress.current}/${importProgress.total}...` : 'Import'}
|
|
</button>
|
|
|
|
{/* Separator */}
|
|
<div className="w-px h-6 bg-slate-700" />
|
|
|
|
<button
|
|
onClick={() => setViewMode('grid')}
|
|
className={`p-2 rounded transition-all ${
|
|
viewMode === 'grid' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
<Grid className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('list')}
|
|
className={`p-2 rounded transition-all ${
|
|
viewMode === 'list' ? 'bg-cinema-gold text-slate-950' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Storyboard Modal */}
|
|
{isCreatingStoryboard && (
|
|
<div className="bg-slate-800 rounded p-4">
|
|
<h3 className="text-sm font-normal text-slate-200 mb-2">Name your storyboard</h3>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newStoryboardName}
|
|
onChange={(e) => setNewStoryboardName(e.target.value)}
|
|
placeholder="Storyboard name..."
|
|
className="flex-1 px-3 py-2 bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:border-cinema-gold focus:outline-none text-sm"
|
|
autoFocus
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateStoryboard()}
|
|
/>
|
|
<button
|
|
onClick={handleCreateStoryboard}
|
|
disabled={!newStoryboardName.trim()}
|
|
className="px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
Create
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsCreatingStoryboard(false); setNewStoryboardName(''); }}
|
|
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-2">
|
|
{selectedImageIds.length} images selected
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import from Backend Modal */}
|
|
{showImportModal && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-slate-800 rounded p-6 max-w-4xl w-full max-h-[80vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-normal text-slate-200">Import from Backend</h2>
|
|
<button
|
|
onClick={() => setShowImportModal(false)}
|
|
className="p-2 hover:bg-slate-700 rounded transition-colors"
|
|
>
|
|
<X className="w-5 h-5 text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{availableSessions.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-500">
|
|
<Database className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg">No backend files found</p>
|
|
<p className="text-sm mt-2">Generate some images or videos to see them here</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-slate-400 mb-4">
|
|
Found {availableSessions.reduce((acc, s) => acc + s.images.length, 0)} image(s) in {availableSessions.length} session(s).
|
|
Files auto-delete after 24 hours.
|
|
{availableSessions.reduce((acc, s) => acc + s.videos.length, 0) > 0 && (
|
|
<span className="block text-amber-400 text-xs mt-1">
|
|
Note: Video import not yet supported (videos must be in generated_videos folder)
|
|
</span>
|
|
)}
|
|
</p>
|
|
|
|
{/* Session list */}
|
|
<div className="space-y-4">
|
|
{availableSessions.map((session) => (
|
|
<div key={session.session_id} className="bg-slate-800 rounded p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-normal text-slate-300">
|
|
Session: <span className="font-mono">{session.session_id.substring(0, 8)}...</span>
|
|
</h3>
|
|
<span className="text-xs text-slate-500">
|
|
{session.images.length} image(s)
|
|
</span>
|
|
</div>
|
|
|
|
{/* Images */}
|
|
{session.images.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-xs font-normal text-slate-400">Images</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{session.images.map((img) => {
|
|
const fileKey = `${session.session_id}:image:${img.filename}`;
|
|
const isSelected = selectedFiles.includes(fileKey);
|
|
const expiresIn = Math.floor(img.time_remaining / 3600);
|
|
|
|
return (
|
|
<div
|
|
key={img.filename}
|
|
onClick={() => toggleFileSelection(session.session_id, 'image', img.filename)}
|
|
className={`relative p-2 rounded border-2 cursor-pointer transition-all ${
|
|
isSelected
|
|
? 'border-cinema-gold bg-cinema-gold/10'
|
|
: 'border-slate-700 hover:border-slate-600'
|
|
}`}
|
|
>
|
|
<div className="aspect-square bg-slate-700/50 rounded flex items-center justify-center mb-2">
|
|
<Image className="w-8 h-8 text-slate-500" />
|
|
</div>
|
|
<p className="text-xs text-slate-400 truncate">{img.filename}</p>
|
|
<p className="text-xs font-mono text-slate-500">
|
|
{img.size_kb} KB • {expiresIn}h left
|
|
</p>
|
|
{isSelected && (
|
|
<div className="absolute top-1 right-1 bg-cinema-gold rounded-full p-1">
|
|
<Check className="w-3 h-3 text-slate-950" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Videos - Hidden for now since import not supported */}
|
|
{false && session.videos.length > 0 && (
|
|
<div className="space-y-2 mt-4">
|
|
<h4 className="text-xs font-normal text-slate-400">Videos (Import Not Supported)</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{session.videos.map((vid) => {
|
|
const expiresIn = Math.floor(vid.time_remaining / 3600);
|
|
|
|
return (
|
|
<div
|
|
key={vid.filename}
|
|
className="relative p-2 rounded border-2 border-slate-700 opacity-50 cursor-not-allowed"
|
|
>
|
|
<div className="aspect-square bg-slate-700/50 rounded flex items-center justify-center mb-2">
|
|
<Video className="w-8 h-8 text-slate-500" />
|
|
</div>
|
|
<p className="text-xs text-slate-400 truncate">{vid.filename}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{vid.size_mb} MB • {expiresIn}h left
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center justify-between mt-6 pt-4">
|
|
<p className="text-sm text-slate-400">
|
|
{selectedFiles.length} file(s) selected
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowImportModal(false)}
|
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm transition-all"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleImportFiles}
|
|
disabled={selectedFiles.length === 0 || importing}
|
|
className="flex items-center gap-2 px-4 py-2 bg-cinema-gold hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-500 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
{importing ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Importing {importProgress.current}/{importProgress.total}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="w-4 h-4" />
|
|
Import {selectedFiles.length} File(s)
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* LIBRARY TAB CONTENT */}
|
|
{activeSubTab === 'library' && (
|
|
<>
|
|
{/* Project Items */}
|
|
{selectedProjectItems.length === 0 ? (
|
|
<div className="text-center py-16 text-slate-500">
|
|
<FolderOpen className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg">This project is empty</p>
|
|
<p className="text-sm mt-2">Generate images or videos and save them here</p>
|
|
</div>
|
|
) : (
|
|
<div className={viewMode === 'grid'
|
|
? 'grid grid-cols-2 md:grid-cols-3 gap-4'
|
|
: 'space-y-2'
|
|
}>
|
|
{selectedProjectItems.map((item) => (
|
|
viewMode === 'grid' ? (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => {
|
|
if (isSelecting && item.type === 'image') {
|
|
toggleImageSelection(item.id);
|
|
} else {
|
|
setPreviewItem(item);
|
|
}
|
|
}}
|
|
className={`group relative aspect-square bg-slate-800 rounded overflow-hidden border transition-all cursor-pointer ${
|
|
isSelecting && selectedImageIds.includes(item.id)
|
|
? 'border-cinema-gold ring-2 ring-cinema-gold'
|
|
: 'border-slate-700 hover:border-cinema-gold'
|
|
}`}
|
|
>
|
|
{/* Selection Checkbox (only for images in selection mode) */}
|
|
{isSelecting && item.type === 'image' && (
|
|
<div className="absolute top-2 right-2 z-10">
|
|
<div className={`w-6 h-6 rounded flex items-center justify-center transition-all ${
|
|
selectedImageIds.includes(item.id)
|
|
? 'bg-cinema-gold text-slate-950'
|
|
: 'bg-slate-900/80 border border-slate-600 text-transparent'
|
|
}`}>
|
|
<Check className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Thumbnail or placeholder — NEVER use full item.data here (causes OOM with many large images) */}
|
|
{item.thumbnail ? (
|
|
<img
|
|
src={item.thumbnail.startsWith('data:') ? item.thumbnail : `data:image/jpeg;base64,${item.thumbnail}`}
|
|
alt={item.prompt}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-slate-800">
|
|
{item.type === 'image' ? (
|
|
<Image className="w-8 h-8 text-slate-600" />
|
|
) : (
|
|
<Video className="w-12 h-12 text-slate-500" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Type Badge */}
|
|
<div className={`absolute top-2 left-2 px-2 py-1 rounded text-xs font-normal ${
|
|
item.type === 'image'
|
|
? 'bg-blue-900/80 text-blue-300'
|
|
: item.settings?.engine === 'kling'
|
|
? 'bg-indigo-900/80 text-indigo-300'
|
|
: 'bg-purple-900/80 text-purple-300'
|
|
}`}>
|
|
{item.type === 'image' ? 'IMG' : item.settings?.engine === 'kling' ? 'KLING' : 'VEO'}
|
|
</div>
|
|
|
|
{/* Hover Overlay (hide when selecting) */}
|
|
{!isSelecting && (
|
|
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
downloadItem(item);
|
|
}}
|
|
className="p-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteItem(item.id);
|
|
}}
|
|
className="p-2 bg-red-600 hover:bg-red-500 text-white rounded"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => {
|
|
if (isSelecting && item.type === 'image') {
|
|
toggleImageSelection(item.id);
|
|
} else {
|
|
setPreviewItem(item);
|
|
}
|
|
}}
|
|
className={`flex items-center gap-4 p-3 bg-slate-800 rounded border transition-all cursor-pointer ${
|
|
isSelecting && selectedImageIds.includes(item.id)
|
|
? 'border-cinema-gold bg-cinema-gold/10'
|
|
: 'border-slate-700 hover:border-slate-600'
|
|
}`}
|
|
>
|
|
{/* Selection Checkbox for list view */}
|
|
{isSelecting && item.type === 'image' && (
|
|
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 transition-all ${
|
|
selectedImageIds.includes(item.id)
|
|
? 'bg-cinema-gold text-slate-950'
|
|
: 'bg-slate-900 border border-slate-600 text-transparent'
|
|
}`}>
|
|
<Check className="w-4 h-4" />
|
|
</div>
|
|
)}
|
|
<div className={`w-10 h-10 rounded flex items-center justify-center flex-shrink-0 ${
|
|
item.type === 'image' ? 'bg-blue-900/50' : 'bg-slate-800'
|
|
}`}>
|
|
{item.type === 'image' ? (
|
|
<Image className="w-5 h-5 text-blue-400" />
|
|
) : (
|
|
<Video className="w-5 h-5 text-slate-500" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-slate-200 truncate">{item.prompt}</p>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<span className="font-mono">{formatDate(item.createdAt)}</span>
|
|
{item.settings?.application && (
|
|
<span className="px-1.5 py-0.5 bg-slate-800 rounded text-slate-400 truncate max-w-[150px]">
|
|
{getPresetDisplayName(item.settings.application)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!isSelecting && (
|
|
<div className="flex items-center gap-1 relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMovingItemId(movingItemId === item.id ? null : item.id);
|
|
}}
|
|
className={`p-2 rounded ${movingItemId === item.id ? 'bg-cinema-gold/20' : 'hover:bg-slate-700'}`}
|
|
title="Move to another project"
|
|
>
|
|
<ArrowRightLeft className={`w-4 h-4 ${movingItemId === item.id ? 'text-cinema-gold' : 'text-slate-400'}`} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
downloadItem(item);
|
|
}}
|
|
className="p-2 hover:bg-slate-700 rounded"
|
|
>
|
|
<Download className="w-4 h-4 text-slate-400" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteItem(item.id);
|
|
}}
|
|
className="p-2 hover:bg-red-900/50 rounded"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-400" />
|
|
</button>
|
|
{movingItemId === item.id && (
|
|
<div className="absolute right-0 top-full mt-1 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="px-3 py-1.5 text-xs font-normal text-slate-500 uppercase tracking-wider">Move to...</div>
|
|
{projects
|
|
.filter(p => p.id !== selectedProject?.id)
|
|
.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await moveItemToProject(item.id, selectedProject.id, p.id);
|
|
setMovingItemId(null);
|
|
loadProjectItems(selectedProject.id);
|
|
} catch (err) {
|
|
console.error('Failed to move item:', err);
|
|
}
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors flex items-center gap-2"
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5 text-slate-500" />
|
|
<span className="truncate">{p.name}</span>
|
|
</button>
|
|
))
|
|
}
|
|
{projects.filter(p => p.id !== selectedProject?.id).length === 0 && (
|
|
<div className="px-3 py-2 text-xs text-slate-500">No other projects</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* STORYBOARDS TAB CONTENT */}
|
|
{activeSubTab === 'storyboards' && (
|
|
<div className="space-y-4">
|
|
{/* Show StoryboardEditor if a storyboard is active */}
|
|
{activeStoryboard ? (
|
|
<StoryboardEditor
|
|
storyboard={activeStoryboard}
|
|
projectItems={selectedProjectItems}
|
|
onBack={() => setActiveStoryboard(null)}
|
|
onUpdate={handleUpdateStoryboard}
|
|
onDelete={handleDeleteStoryboard}
|
|
onGenerateVideo={handleGenerateVideoFromFrame}
|
|
onEditFrames={handleEditStoryboardFrames}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* Storyboards Header */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-slate-400">
|
|
{storyboards.length} storyboard{storyboards.length !== 1 ? 's' : ''}
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setActiveSubTab('library');
|
|
setIsSelecting(true);
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
New Storyboard
|
|
</button>
|
|
</div>
|
|
|
|
{/* Storyboards List */}
|
|
{storyboards.length === 0 ? (
|
|
<div className="text-center py-16 text-slate-500">
|
|
<Layers className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
|
<p className="text-lg">No storyboards yet</p>
|
|
<p className="text-sm mt-2">Select images from your library to create a storyboard</p>
|
|
<button
|
|
onClick={() => {
|
|
setActiveSubTab('library');
|
|
setIsSelecting(true);
|
|
}}
|
|
className="mt-4 px-4 py-2 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-all"
|
|
>
|
|
Select Images
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{storyboards.map((board) => (
|
|
<div
|
|
key={board.id}
|
|
onClick={() => handleOpenStoryboard(board.id)}
|
|
className="group p-4 bg-slate-800 rounded hover:bg-slate-700 transition-all cursor-pointer"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-normal text-slate-200">{board.name}</h3>
|
|
<span className="text-xs text-slate-500">{board.frames?.length || 0} frames</span>
|
|
</div>
|
|
{/* Frame thumbnails preview */}
|
|
<div className="flex gap-1 mb-3">
|
|
{board.frames?.slice(0, 4).map((frame, idx) => {
|
|
const frameItem = selectedProjectItems.find(item => item.id === frame.imageId);
|
|
return (
|
|
<div key={idx} className="w-12 h-12 bg-slate-700 rounded overflow-hidden">
|
|
{frameItem && (
|
|
<img
|
|
src={
|
|
frameItem.thumbnail
|
|
? (frameItem.thumbnail.startsWith('data:') ? frameItem.thumbnail : `data:image/jpeg;base64,${frameItem.thumbnail}`)
|
|
: (frameItem.data.startsWith('data:') || frameItem.data.startsWith('http'))
|
|
? frameItem.data
|
|
: `data:${frameItem.mimeType};base64,${frameItem.data}`
|
|
}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{(board.frames?.length || 0) > 4 && (
|
|
<div className="w-12 h-12 bg-slate-700 rounded flex items-center justify-center text-xs text-slate-400">
|
|
+{board.frames.length - 4}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-xs font-mono text-slate-500">
|
|
Updated {formatDate(board.updatedAt)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-slate-500 py-16">
|
|
<FolderOpen className="w-20 h-20 mb-4 opacity-30" />
|
|
<p className="text-lg">Select a project</p>
|
|
<p className="text-sm mt-2">Choose a project from the sidebar or create a new one</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Modal */}
|
|
{previewItem && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
|
onClick={() => { setPreviewItem(null); setPreviewExpanded(false); }}
|
|
>
|
|
<div
|
|
className={`relative max-h-[90vh] w-full mx-4 transition-all duration-300 ${previewExpanded ? 'max-w-[95vw]' : 'max-w-4xl'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Close + Expand buttons */}
|
|
<div className="absolute -top-12 right-0 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setPreviewExpanded(!previewExpanded)}
|
|
className="p-2 text-white hover:text-cinema-gold transition-colors"
|
|
title={previewExpanded ? 'Collapse' : 'Expand'}
|
|
>
|
|
{previewExpanded ? <Minimize2 className="w-6 h-6" /> : <Maximize2 className="w-6 h-6" />}
|
|
</button>
|
|
<button
|
|
onClick={() => { setPreviewItem(null); setPreviewExpanded(false); }}
|
|
className="p-2 text-white hover:text-cinema-gold transition-colors"
|
|
>
|
|
<X className="w-8 h-8" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="bg-slate-800 rounded overflow-hidden">
|
|
{previewItem.type === 'image' ? (
|
|
<img
|
|
src={
|
|
previewItem.data.startsWith('data:') || previewItem.data.startsWith('http')
|
|
? previewItem.data
|
|
: `data:${previewItem.mimeType};base64,${previewItem.data}`
|
|
}
|
|
alt={previewItem.prompt}
|
|
className={`w-full h-auto object-contain transition-all duration-300 ${previewExpanded ? 'max-h-[80vh]' : 'max-h-[70vh]'}`}
|
|
/>
|
|
) : (
|
|
<div className="flex justify-center">
|
|
<VideoPlayer
|
|
src={previewItem.data}
|
|
autoPlay={true}
|
|
muted={false}
|
|
onSaveToProject={selectedProject ? async (frameData) => {
|
|
await addItemToProject(selectedProject.id, {
|
|
type: 'image',
|
|
prompt: frameData.prompt,
|
|
data: frameData.data,
|
|
mimeType: frameData.mimeType,
|
|
thumbnail: frameData.data
|
|
});
|
|
// Reload items to show the new frame
|
|
loadProjectItems(selectedProject.id);
|
|
} : null}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info bar */}
|
|
<div className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="text-sm text-slate-300 line-clamp-2 flex-1">{previewItem.prompt}</p>
|
|
{previewItem.prompt && (
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(previewItem.prompt);
|
|
setCopiedPrompt(true);
|
|
setTimeout(() => setCopiedPrompt(false), 2000);
|
|
}}
|
|
className="flex items-center gap-1.5 px-2.5 py-1 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-normal transition-colors shrink-0"
|
|
title="Copy prompt to clipboard"
|
|
>
|
|
{copiedPrompt ? (
|
|
<>
|
|
<Check className="w-3 h-3 text-emerald-400" />
|
|
<span className="text-emerald-400">Copied</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="w-3 h-3" />
|
|
<span>Copy Prompt</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{previewItem.settings?.application && (
|
|
<div className="mt-2">
|
|
<span className="text-xs px-2 py-1 bg-slate-800 rounded-full text-slate-400 border border-slate-700">
|
|
{getPresetDisplayName(previewItem.settings.application)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{previewItem.type === 'video' && (
|
|
<div className="mt-2 flex gap-1.5">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-normal ${
|
|
previewItem.settings?.engine === 'kling'
|
|
? 'bg-indigo-900/50 text-indigo-300 border border-indigo-700'
|
|
: 'bg-emerald-900/50 text-emerald-300 border border-emerald-700'
|
|
}`}>
|
|
{previewItem.settings?.engine === 'kling'
|
|
? `Kling ${previewItem.settings?.klingModel?.replace('kling-', '').toUpperCase() || 'AI'}`
|
|
: `Veo ${previewItem.settings?.modelType === 'fast' ? 'Fast' : 'Std'}`}
|
|
</span>
|
|
{previewItem.settings?.engine === 'kling' && previewItem.settings?.klingTaskId && (
|
|
<span className="text-xs px-2 py-0.5 bg-slate-800 rounded-full text-slate-500 border border-slate-700 font-mono">
|
|
ID: {previewItem.settings.klingTaskId.substring(0, 8)}...
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between mt-3">
|
|
<span className="text-xs font-mono text-slate-500">{formatDate(previewItem.createdAt)}</span>
|
|
<div className="flex gap-2">
|
|
{/* Edit in Image Gen button for images */}
|
|
{previewItem.type === 'image' && onEditInImageGen && (
|
|
<button
|
|
onClick={() => {
|
|
onEditInImageGen({
|
|
prompt: previewItem.prompt,
|
|
imageData: previewItem.data.startsWith('data:') || previewItem.data.startsWith('http')
|
|
? previewItem.data
|
|
: `data:${previewItem.mimeType};base64,${previewItem.data}`,
|
|
settings: previewItem.settings
|
|
});
|
|
setPreviewItem(null);
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-200 hover:bg-slate-300 text-slate-900 rounded text-sm font-normal transition-colors"
|
|
>
|
|
<Wand2 className="w-4 h-4" />
|
|
Edit in Image Gen
|
|
</button>
|
|
)}
|
|
{/* Generate Video from image (load as first frame) */}
|
|
{previewItem.type === 'image' && onRerunVideo && (
|
|
<button
|
|
onClick={() => {
|
|
onRerunVideo({
|
|
prompt: '',
|
|
settings: {},
|
|
referenceImages: [{
|
|
data: previewItem.data,
|
|
mime_type: previewItem.mimeType || 'image/png'
|
|
}]
|
|
});
|
|
setPreviewItem(null);
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-colors"
|
|
>
|
|
<Video className="w-4 h-4" />
|
|
Generate Video
|
|
</button>
|
|
)}
|
|
{/* Re-run button for videos */}
|
|
{previewItem.type === 'video' && onRerunVideo && (
|
|
<button
|
|
onClick={() => {
|
|
onRerunVideo({
|
|
prompt: previewItem.prompt,
|
|
settings: previewItem.settings,
|
|
referenceImages: previewItem.referenceImages
|
|
});
|
|
setPreviewItem(null);
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-200 hover:bg-slate-300 text-slate-900 rounded text-sm font-normal transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Re-run
|
|
</button>
|
|
)}
|
|
{/* Move to another project */}
|
|
{projects.filter(p => p.id !== selectedProject?.id).length > 0 && (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setMovingItemId(movingItemId === previewItem.id ? null : previewItem.id)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-normal transition-colors ${
|
|
movingItemId === previewItem.id
|
|
? 'bg-cinema-gold text-slate-950'
|
|
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
|
|
}`}
|
|
>
|
|
<ArrowRightLeft className="w-4 h-4" />
|
|
Move to
|
|
</button>
|
|
{movingItemId === previewItem.id && (
|
|
<div className="absolute bottom-full mb-1 right-0 z-20 bg-slate-800 border border-slate-700 rounded py-1 min-w-[180px]">
|
|
{projects
|
|
.filter(p => p.id !== selectedProject?.id)
|
|
.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={async () => {
|
|
try {
|
|
await moveItemToProject(previewItem.id, selectedProject.id, p.id);
|
|
setMovingItemId(null);
|
|
setPreviewItem(null);
|
|
loadProjectItems(selectedProject.id);
|
|
} catch (err) {
|
|
console.error('Failed to move item:', err);
|
|
}
|
|
}}
|
|
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors flex items-center gap-2"
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5 text-slate-500" />
|
|
<span className="truncate">{p.name}</span>
|
|
</button>
|
|
))
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => downloadItem(previewItem)}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-cinema-gold hover:bg-amber-400 text-slate-950 rounded text-sm font-normal transition-colors"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProjectsTab;
|