cinema-studio-pro/frontend/src/components/ProjectsTab.jsx
Simeon.Schecter 95e6946807 feat: Kling integration, prompt optimizer rework, and stability fixes
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>
2026-03-23 21:51:03 -04:00

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;