import React, { useState, useMemo } from 'react'; import { FolderOpen, History, DownloadCloud, Film, Save, ChevronDown, ChevronUp, Fingerprint, Wand2, Lock, Unlock, Trash2, Check, Clock, Play, Loader, Download as DownloadIcon, Volume2, Search, X, } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { API } from '../api/client'; import { clearDubHistory } from '../api/dub'; import { clearHistory as clearGenHistory } from '../api/generate'; import { Button } from '../ui'; import { useAppStore } from '../store'; import './Sidebar.css'; import { askConfirm } from '../utils/dialog'; const SIDEBAR_TABS = [ { id: 'projects', icon: FolderOpen, accent: '#b8bb26' }, { id: 'history', icon: History, accent: '#d3869b' }, { id: 'downloads', icon: DownloadCloud, accent: '#8ec07c' }, ]; function timeAgo(ms) { const diff = Date.now() - ms; if (!isFinite(diff) || diff < 0) return ''; const s = Math.floor(diff / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' }); } export default function Sidebar(props) { const { availableTabs = ['projects', 'history', 'downloads'], isSidebarProjectsCollapsed, setIsSidebarProjectsCollapsed, sidebarTab, setSidebarTab, studioProjects, profiles, history, dubHistory, exportHistory, dubVideoFile, selectedProfile, previewLoading, saveProject, loadProject, deleteProject, handleSelectProfile, handleDeleteProfile, handleOpenVoiceProfile, handleUnlockProfile, handleLockProfile, handlePreviewVoice, onOpenVoicePreview, restoreHistory, restoreDubHistory, handleSaveHistoryAsProfile, handleNativeExport, revealInFolder, deleteHistory, loadHistory, loadDubHistory, } = props; // Phase 2.2 — read UI + dub state straight from the store. const mode = useAppStore(s => s.mode); const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed); const dubStep = useAppStore(s => s.dubStep); const activeProjectId = useAppStore(s => s.activeProjectId); const [sbQuery, setSbQuery] = useState(''); const qLower = sbQuery.trim().toLowerCase(); const matchesSearch = (s) => !qLower || (s || '').toLowerCase().includes(qLower); const filteredProjects = useMemo(() => studioProjects.filter(p => matchesSearch(p.name) || matchesSearch(p.video_path) ), [studioProjects, qLower]); const filteredProfiles = useMemo(() => profiles.filter(p => matchesSearch(p.name) || matchesSearch(p.instruct) ), [profiles, qLower]); const filteredHistory = useMemo(() => history.filter(i => matchesSearch(i.text) || matchesSearch(i.language) || matchesSearch(String(i.seed)) ), [history, qLower]); const filteredDubHistory = useMemo(() => dubHistory.filter(i => matchesSearch(i.filename) || matchesSearch(i.language) || matchesSearch(i.language_code) ), [dubHistory, qLower]); const filteredExport = useMemo(() => exportHistory.filter(i => matchesSearch(i.filename) || matchesSearch(i.destination_path) ), [exportHistory, qLower]); const handleClearHistory = async () => { if (!(await askConfirm(`Clear all ${history.length + dubHistory.length} history items? This cannot be undone.`))) return; await clearGenHistory(); await clearDubHistory(); await loadHistory(); await loadDubHistory(); toast.success('History cleared'); }; const tabCount = { projects: mode === 'dub' ? studioProjects.length : (mode === 'clone' ? profiles.filter(p => !p.instruct).length : profiles.filter(p => !!p.instruct).length), history: history.length + dubHistory.length, downloads: exportHistory.length, }; const tabLabel = { projects: 'Drive', history: 'History', downloads: 'Exports' }; return (
{/* Tab bar — only tabs relevant to the current view */}
{SIDEBAR_TABS.filter(t => availableTabs.includes(t.id)).map(({ id, icon: Icon, accent }) => ( ))}
{!isSidebarCollapsed && (
setSbQuery(e.target.value)} /> {sbQuery && ( )}
)}
{/* ── PROJECTS TAB ── */} {sidebarTab === 'projects' && ( <> {mode === 'dub' && (dubStep !== 'idle' || dubVideoFile) && ( isSidebarCollapsed ? ( ) : ( ) )} {!isSidebarCollapsed && (
setIsSidebarProjectsCollapsed(!isSidebarProjectsCollapsed)} > {mode === 'dub' ? 'Dub Projects' : (mode === 'clone' ? 'Voice Clones' : 'Designed Voices')} {isSidebarProjectsCollapsed ? : }
)} {!isSidebarProjectsCollapsed && !isSidebarCollapsed && ( <> {mode === 'dub' && ( <> {filteredProjects.length === 0 ? ( ) : ( filteredProjects.map(proj => (
loadProject(proj.id)} >
Dub {timeAgo(proj.updated_at * 1000)}
{proj.name}
{proj.duration ? `${Math.round(proj.duration)}s` : 'audio'} {(() => { const basename = proj.video_path ? proj.video_path.split(/[\\/]/).pop() : ''; // Skip echoing the filename when it already matches the project name. return basename && basename !== proj.name ? ` · ${basename}` : ''; })()}
)) )} )} {(mode === 'clone' || mode === 'design') && ( <> {filteredProfiles.filter(p => mode === "clone" ? !p.instruct : !!p.instruct).length === 0 ? ( ) : ( (mode === 'clone' ? filteredProfiles.filter(p => !p.instruct) : filteredProfiles.filter(p => !!p.instruct)).map(proj => { const accent = proj.is_locked ? '#b8bb26' : (mode === 'clone' ? '#d3869b' : '#8ec07c'); const KindIcon = proj.is_locked ? Lock : (mode === 'clone' ? Fingerprint : Wand2); return (
handleSelectProfile(proj)} >
{proj.is_locked ? 'Locked' : (mode === 'clone' ? 'Clone' : 'Design')} {proj.is_locked ? consistent : null}
{proj.name}
{proj.instruct ?
{proj.instruct}
: null}
{handleOpenVoiceProfile && ( )} {onOpenVoicePreview && ( )} {proj.is_locked ? ( ) : null}
); }) )} )} )} {isSidebarCollapsed && mode === 'dub' && filteredProjects.map(proj => ( loadProject(proj.id)} active={activeProjectId === proj.id} rotSeed={proj.id} > ))} {isSidebarCollapsed && (mode === 'clone' || mode === 'design') && (mode === 'clone' ? filteredProfiles.filter(p => !p.instruct) : filteredProfiles.filter(p => !!p.instruct)).map(proj => ( handleSelectProfile(proj)} active={selectedProfile === proj.id} rotSeed={proj.id} > {mode === 'clone' ? : } {proj.is_locked && } ))} )} {/* ── HISTORY TAB ── */} {sidebarTab === 'history' && ( <> {!isSidebarCollapsed &&
Generation history · Stored in SQLite
} {(history.length + dubHistory.length) === 0 ? ( ) : ( <> {!isSidebarCollapsed && filteredDubHistory.map(item => (
restoreDubHistory(item)} >
Dub {item.segments_count} segs · {Math.round(item.duration || 0)}s
{item.filename}
{[item.language, item.language_code].filter(v => v && v !== 'und' && v !== 'Auto').join(' · ') || 'Auto'}
))} {!isSidebarCollapsed && filteredHistory.map(item => { const accent = item.mode === 'clone' ? '#d3869b' : '#b8bb26'; const KindIcon = item.mode === 'clone' ? Fingerprint : Wand2; return (
{item.mode || 'synth'} {item.language && item.language !== 'Auto' ? `${item.language} · ` : ''} {item.generation_time ? `${item.generation_time}s` : ''}
{item.text}
{item.seed != null && String(item.seed) !== '' ?
seed {item.seed}
: null} {item.audio_path ? (
); })} )} {isSidebarCollapsed && filteredDubHistory.map(item => (
restoreDubHistory(item)} className="sidebar-tile sidebar-tile--audio">
))} {isSidebarCollapsed && filteredHistory.map(item => (
restoreHistory(item)} className={`sidebar-tile ${item.mode === 'clone' ? 'sidebar-tile--clone' : 'sidebar-tile--design'}`}> {item.mode === 'clone' ? : }
))} {(history.length + dubHistory.length) > 0 && !isSidebarCollapsed && ( )} )} {/* ── DOWNLOADS TAB ── */} {sidebarTab === 'downloads' && ( <> {!isSidebarCollapsed &&
Recent Exports
} {exportHistory.length === 0 ? ( ) : ( <> {!isSidebarCollapsed && filteredExport.map(item => { const pathParts = item.destination_path.split(/[\\/]/); const parentFolder = pathParts.length > 1 ? pathParts[pathParts.length - 2] : '…'; const accent = item.mode === 'audio' ? '#83a598' : '#8ec07c'; const KindIcon = item.mode === 'audio' ? Volume2 : Film; return (
revealInFolder(item.destination_path)} >
{item.mode} {timeAgo(item.created_at * 1000)}
{item.filename}
in {parentFolder}
); })} {isSidebarCollapsed && filteredExport.map(item => (
revealInFolder(item.destination_path)} className={`sidebar-tile ${item.mode === 'audio' ? 'sidebar-tile--audio' : 'sidebar-tile--success'}`} >
))} )} )}
); } /** * EmptyState — shared "nothing here yet" card across the three sidebar tabs. */ function EmptyState({ icon: Icon, title, hint }) { return (

{title}

{hint}

); } /** * IconTile — hand-drawn sticker-style tile used in the collapsed-sidebar grid. * Deterministic rotation based on the id's last char keeps tiles wonky but stable. */ function IconTile({ children, title, onClick, active, rotSeed }) { const tilt = ((parseInt((rotSeed || '0').slice(-1), 36) % 5) - 2) * 0.8; return (
{children}
); }