// Main app shell — wired to the live-gallery FastAPI backend function App() { // Auth — null=loading, true/false const [authed, setAuthed] = React.useState(null); const [displayName, setDisplayName] = React.useState(''); const [theme, setTheme] = React.useState(() => localStorage.getItem('studio.theme') || 'graphite'); const [soundOn, setSoundOn] = React.useState(() => localStorage.getItem('studio.sound') !== 'off'); // Onboarding & profile const [showWelcome, setShowWelcome] = React.useState(false); const [showProfilePhoto, setShowProfilePhoto] = React.useState(false); const [showBranding, setShowBranding] = React.useState(false); const [profilePhotoUrl, setProfilePhotoUrl] = React.useState(null); // Sidebar const [sidebarOpen, setSidebarOpen] = React.useState(() => window.innerWidth >= 768); // View state const [view, setView] = React.useState('grid'); const [sort, setSort] = React.useState('capture_desc'); const [density, setDensity] = React.useState('normal'); const [searchQuery, setSearchQuery] = React.useState(''); const [analyticsOpen, setAnalyticsOpen] = React.useState(false); // Filters const [tagFilter, setTagFilter] = React.useState(null); const [ratingFilter, setRatingFilter] = React.useState(0); const [flagFilter, setFlagFilter] = React.useState('all'); const [fileTypeFilter, setFileTypeFilter] = React.useState('all'); const [activeCollection, setActiveCollection] = React.useState(null); const [toolbarFilter, setToolbarFilter] = React.useState('all'); const [colorFilter, setColorFilter] = React.useState(null); const [collectionsDrawerOpen, setCollectionsDrawerOpen] = React.useState(false); // Selection + focus const [selected, setSelected] = React.useState(new Set()); const [focused, setFocused] = React.useState(0); const [lightboxIdx, setLightboxIdx] = React.useState(null); // Lightbox state const [showExif, setShowExif] = React.useState(false); const [zoom, setZoom] = React.useState(false); const [baMode, setBaMode] = React.useState(false); const [devOpen, setDevOpen] = React.useState(false); // Modals const [shareOpen, setShareOpen] = React.useState(false); const [watermarkOpen, setWatermarkOpen] = React.useState(false); const [newCollectionOpen, setNewCollectionOpen] = React.useState(false); const [shortcutsOpen, setShortcutsOpen] = React.useState(false); const [shareViewOpen, setShareViewOpen] = React.useState(false); // Server data const [collections, setCollections] = React.useState([]); const [shares, setShares] = React.useState([]); const [watermarkCfg, setWatermarkCfg] = React.useState({ text: '', opacity: 65, position: 'bottom-right', size: 'medium' }); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(false); const [storageInfo, setStorageInfo] = React.useState(null); // Edits (local, save on demand) const [edits, setEdits] = React.useState({}); // EXIF cache (lazy) const [exifCache, setExifCache] = React.useState({}); // Tether / capture const [capturing, setCapturing] = React.useState(false); const [lastCapture, setLastCapture] = React.useState(''); // Share preview feedback const [feedback, setFeedback] = React.useState(new Set()); // Events const [events, setEvents] = React.useState([]); const [activeEvent, setActiveEvent] = React.useState(null); // id or null (all) const [activeShootId, setActiveShootId] = React.useState(null); // event receiving new photos const [eventModalOpen, setEventModalOpen] = React.useState(false); // Client moodboards (from share links) const [moodboards, setMoodboards] = React.useState([]); // Second screen BroadcastChannel const bcRef = React.useRef(null); React.useEffect(() => { try { bcRef.current = new BroadcastChannel('livegallery-secondscreen'); } catch {} return () => { try { bcRef.current && bcRef.current.close(); } catch {} }; }, []); const broadcastPhoto = React.useCallback((file) => { if (!file || !bcRef.current) return; try { bcRef.current.postMessage({ type: 'photo', filename: file.filename, url: file.thumb_url, pick: !!file.pick, reject: !!file.reject, rating: file.rating || 0, color: file.color || null, tags: file.tags || [], }); } catch {} }, []); const openSecondScreen = React.useCallback(() => { window.open('/secondscreen', '_blank', 'noopener'); }, []); // Toast notifications const [toasts, setToasts] = React.useState([]); const addToast = React.useCallback((msg, type = 'error') => { const id = Date.now() + Math.random(); setToasts(ts => [...ts, { id, msg, type }]); setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 4500); }, []); // ── Auth check on mount ────────────────────────────────────────────────── React.useEffect(() => { fetch('/auth/me') .then(r => r.ok ? r.json() : null) .then(d => { if (d) { setAuthed(true); setDisplayName(d.display_name || d.username || ''); if (d.first_login) setShowWelcome(true); setProfilePhotoUrl(`/api/profile/photo?t=${Date.now()}`); } else { setAuthed(false); } }) .catch(() => setAuthed(false)); }, []); // ── Load data when authed ───────────────────────────────────────────────── React.useEffect(() => { if (!authed) return; setLoading(true); fetch('/api/gallery') .then(r => { if (!r.ok) { if (r.status === 401) setAuthed(false); throw new Error(); } return r.json(); }) .then(data => { setFiles(data); setLoading(false); }) .catch(() => setLoading(false)); fetch('/api/collections').then(r => r.ok ? r.json() : []).then(setCollections).catch(() => {}); fetch('/api/shares').then(r => r.ok ? r.json() : []).then(setShares).catch(() => {}); fetch('/api/moodboards').then(r => r.ok ? r.json() : []).then(setMoodboards).catch(() => {}); fetch('/api/events').then(r => r.ok ? r.json() : { events: [], active_id: null }) .then(d => { setEvents(d.events || []); setActiveShootId(d.active_id || null); }) .catch(() => {}); fetch('/api/watermark').then(r => r.ok ? r.json() : null).then(d => d && setWatermarkCfg(p => ({ ...p, ...d }))).catch(() => {}); fetch('/api/storage').then(r => r.ok ? r.json() : null).then(d => d && setStorageInfo(d)).catch(() => {}); }, [authed]); // ── WebSocket — real-time tether ────────────────────────────────────────── React.useEffect(() => { if (!authed) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws; try { ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.event === 'new_file') { setCapturing(false); setLastCapture('just now'); setFiles(fs => [{ ...msg, isNew: true }, ...fs]); setTimeout(() => setFiles(fs => fs.map(f => f.filename === msg.filename ? { ...f, isNew: false } : f)), 1200); // Update event photo count if (msg.event_id) { setEvents(es => es.map(e => e.id === msg.event_id ? { ...e, count: (e.count || 0) + 1 } : e)); } // Push new capture to second screen immediately try { if (bcRef.current) { bcRef.current.postMessage({ type: 'photo', filename: msg.filename, url: msg.thumb_url, pick: false, reject: false, rating: 0, color: null, tags: [], isNew: true, }); } } catch {} } } catch {} }; } catch {} return () => { if (ws) ws.close(); }; }, [authed]); // ── Theme ───────────────────────────────────────────────────────────────── React.useEffect(() => { document.body.dataset.theme = theme; localStorage.setItem('studio.theme', theme); }, [theme]); // ── Shutter sound ────────────────────────────────────────────────────────── const playShutter = React.useCallback(() => { if (!soundOn) return; try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); // Two-layer click: a short mechanical tick + a body thud const tick = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.018), ctx.sampleRate); const thud = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 0.055), ctx.sampleRate); const td = tick.getChannelData(0); const bd = thud.getChannelData(0); for (let i = 0; i < td.length; i++) td[i] = (Math.random()*2-1) * Math.exp(-i/(ctx.sampleRate*0.003)); for (let i = 0; i < bd.length; i++) bd[i] = (Math.random()*2-1) * Math.exp(-i/(ctx.sampleRate*0.018)) * 0.35; const g1 = ctx.createGain(); g1.gain.value = 0.55; const g2 = ctx.createGain(); g2.gain.value = 0.4; const s1 = ctx.createBufferSource(); s1.buffer = tick; s1.connect(g1); g1.connect(ctx.destination); const s2 = ctx.createBufferSource(); s2.buffer = thud; s2.connect(g2); g2.connect(ctx.destination); s1.start(0); s2.start(ctx.currentTime + 0.012); setTimeout(() => ctx.close(), 300); } catch {} }, [soundOn]); // ── Derived state ───────────────────────────────────────────────────────── const stats = React.useMemo(() => { const picks = files.filter(f => f.pick).length; const rejects = files.filter(f => f.reject).length; const rated = files.filter(f => (f.rating||0) > 0).length; const rated3 = files.filter(f => (f.rating||0) >= 3).length; const avgRating = rated ? files.reduce((s,f) => s + (f.rating||0), 0) / rated : 0; const unflagged = files.length - picks - rejects; const blurred = files.filter(f => f.blur).length; const unreviewed = files.filter(f => !f.pick && !f.reject && !(f.rating||0)).length; const uploadsGB = storageInfo ? storageInfo.uploads_size / (1024*1024*1024) : null; const unassigned = files.filter(f => !f.event_id).length; return { total: files.length, picks, rejects, rated, rated3, unflagged, avgRating, blurred, unreviewed, unassigned, sessionSize: uploadsGB ? `${uploadsGB.toFixed(1)} GB` : '—', }; }, [files, storageInfo]); const tagCounts = React.useMemo(() => { const c = {}; files.forEach(f => (f.tags||[]).forEach(t => { c[t] = (c[t]||0) + 1; })); return c; }, [files]); const visibleFiles = React.useMemo(() => { let f = files; // Event filter if (activeEvent === '_unassigned') f = f.filter(x => !x.event_id); else if (activeEvent) f = f.filter(x => x.event_id === activeEvent); // Smart collection filters from CollectionsDrawer if (activeCollection === '_picks') f = f.filter(x => x.pick); else if (activeCollection === '_rejected') f = f.filter(x => x.reject); else if (activeCollection === '_rated') f = f.filter(x => (x.rating||0) >= 3); else if (activeCollection === '_blur') f = f.filter(x => x.blur); else if (activeCollection && activeCollection.startsWith('_mb_')) { const token = activeCollection.slice(4); const mb = moodboards.find(m => m.token === token); const mbFiles = mb ? (mb.pins || []) : []; f = f.filter(x => mbFiles.includes(x.filename)); } else if (activeCollection) { const c = collections.find(c => c.name === activeCollection); const cFiles = c ? (c.files || []) : []; f = f.filter(x => cFiles.includes(x.filename)); } // Sidebar flag filter if (flagFilter === 'picks') f = f.filter(x => x.pick); else if (flagFilter === 'rejects') f = f.filter(x => x.reject); else if (flagFilter === 'unflagged') f = f.filter(x => !x.pick && !x.reject); // Toolbar quick filter if (toolbarFilter === 'RAW') f = f.filter(x => x.file_type === 'RAW'); else if (toolbarFilter === 'JPG') f = f.filter(x => x.file_type === 'JPG' || x.file_type === 'JPEG'); else if (toolbarFilter === 'picks') f = f.filter(x => x.pick); else if (toolbarFilter === 'rejected') f = f.filter(x => x.reject); else if (toolbarFilter === 'rated3') f = f.filter(x => (x.rating||0) >= 3); else if (toolbarFilter === 'unreviewed') f = f.filter(x => !x.pick && !x.reject && !(x.rating||0)); // Color label filter if (colorFilter) f = f.filter(x => x.color === colorFilter); if (ratingFilter > 0) f = f.filter(x => (x.rating||0) >= ratingFilter); if (fileTypeFilter !== 'all') f = f.filter(x => x.file_type === fileTypeFilter); if (tagFilter) f = f.filter(x => (x.tags||[]).includes(tagFilter)); if (searchQuery) { const q = searchQuery.toLowerCase(); f = f.filter(x => x.filename.toLowerCase().includes(q) || (x.tags||[]).some(t => t.includes(q))); } const sorted = [...f]; if (sort === 'capture_desc') sorted.sort((a,b) => b.timestamp - a.timestamp); else if (sort === 'capture_asc') sorted.sort((a,b) => a.timestamp - b.timestamp); else if (sort === 'rating_desc') sorted.sort((a,b) => (b.rating||0) - (a.rating||0)); else if (sort === 'filename_asc') sorted.sort((a,b) => a.filename.localeCompare(b.filename)); else if (sort === 'size_desc') sorted.sort((a,b) => parseFloat(b.size) - parseFloat(a.size)); return sorted; }, [files, flagFilter, ratingFilter, fileTypeFilter, tagFilter, searchQuery, sort, activeCollection, collections, toolbarFilter, colorFilter, moodboards]); // ── File update helper (optimistic + API sync) ──────────────────────────── const updateFile = React.useCallback((name, patch) => { setFiles(prev => { const next = prev.map(f => f.filename === name ? { ...f, ...patch } : f); const file = next.find(f => f.filename === name); if (file) { fetch(`/api/rate/${encodeURIComponent(name)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: file.rating || 0, pick: !!file.pick, reject: !!file.reject, tags: file.tags || [], color: file.color || '', }), }).catch(() => addToast('Failed to save rating — check your connection')); } return next; }); }, [addToast]); // ── Actions ─────────────────────────────────────────────────────────────── const filesRef = React.useRef(files); React.useEffect(() => { filesRef.current = files; }, [files]); const actions = React.useMemo(() => ({ toggleSelect: (name, on) => setSelected(s => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; }), openLightbox: (idx) => setLightboxIdx(idx), pick: (name) => { const f = filesRef.current.find(f => f.filename === name); if (f) { updateFile(name, { pick: !f.pick, reject: false }); if (!f.pick) playShutter(); } }, reject: (name) => { const f = filesRef.current.find(f => f.filename === name); if (f) updateFile(name, { reject: !f.reject, pick: false }); }, rate: (name, r) => { const f = filesRef.current.find(f => f.filename === name); if (f) updateFile(name, { rating: f.rating === r ? 0 : r }); }, color: (name, c) => { const f = filesRef.current.find(f => f.filename === name); if (f) updateFile(name, { color: f.color === c ? null : c }); }, tag: (name, t) => { const f = filesRef.current.find(f => f.filename === name); if (f) updateFile(name, { tags: Array.from(new Set([...(f.tags||[]), t])) }); }, removeTag: (name, t) => { const f = filesRef.current.find(f => f.filename === name); if (f) updateFile(name, { tags: (f.tags||[]).filter(x => x !== t) }); }, setTagFilter: (t) => setTagFilter(t), }), [updateFile, playShutter]); // ── Bulk actions ────────────────────────────────────────────────────────── const bulkApply = (patch) => { const names = Array.from(selected); setFiles(fs => fs.map(f => selected.has(f.filename) ? { ...f, ...patch } : f)); fetch('/api/rate-bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: names, ...patch }), }).catch(() => {}); }; // ── Auth handlers ───────────────────────────────────────────────────────── const handleLogin = async (username, password) => { const r = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (r.ok) { const d = await r.json().catch(() => ({})); setAuthed(true); setDisplayName(d.display_name || username); if (d.first_login) setShowWelcome(true); setProfilePhotoUrl(`/api/profile/photo?t=${Date.now()}`); return { ok: true }; } const d = await r.json().catch(() => ({})); return { ok: false, message: d.detail || 'Incorrect username or password.' }; }; const handleLogout = async () => { await fetch('/auth/logout', { method: 'POST' }).catch(() => {}); setAuthed(false); setFiles([]); setCollections([]); setShares([]); }; // ── Collection handlers ─────────────────────────────────────────────────── const createCollection = async (name) => { const r = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!r.ok) return; const selectedFiles = Array.from(selected); if (selectedFiles.length > 0) { await fetch(`/api/collections/${encodeURIComponent(name)}/add`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: selectedFiles }), }).catch(() => {}); } setCollections(cs => [...cs, { name, files: selectedFiles, count: selectedFiles.length }]); }; // ── Share handlers ──────────────────────────────────────────────────────── const createShare = async ({ label, filter, expires }) => { const expDays = expires === '1d' ? 1 : expires === '7d' ? 7 : expires === '30d' ? 30 : null; const r = await fetch('/api/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, filter, ...(expDays ? { expires_days: expDays } : {}) }), }); if (!r.ok) return null; const data = await r.json(); const newShare = { token: data.token, label, filter, views: 0, expires: expDays ? Date.now() + expDays * 86400000 : null, }; setShares(ss => [...ss, newShare]); return newShare; }; const revokeShare = async (token) => { await fetch(`/api/share/${token}`, { method: 'DELETE' }).catch(() => {}); setShares(ss => ss.filter(s => s.token !== token)); }; // ── Collection delete ───────────────────────────────────────────────────── const deleteCollection = async (name) => { await fetch(`/api/collections/${encodeURIComponent(name)}`, { method: 'DELETE' }).catch(() => {}); setCollections(cs => cs.filter(c => c.name !== name)); if (activeCollection === name) setActiveCollection(null); }; // ── Event handlers ──────────────────────────────────────────────────────── const createEvent = async ({ name, date, activate }) => { const r = await fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, date, activate }), }); if (!r.ok) return; const ev = await r.json(); setEvents(es => [ev, ...es]); if (activate) setActiveShootId(ev.id); }; const activateEvent = async (id) => { await fetch(`/api/events/${id || 'none'}/activate`, { method: 'POST' }).catch(() => {}); setActiveShootId(id || null); }; const deleteEvent = async (id) => { await fetch(`/api/events/${id}`, { method: 'DELETE' }).catch(() => {}); setEvents(es => es.filter(e => e.id !== id)); if (activeShootId === id) setActiveShootId(null); if (activeEvent === id) setActiveEvent(null); }; // ── Watermark handler ───────────────────────────────────────────────────── const saveWatermark = async (cfg) => { setWatermarkCfg(cfg); await fetch('/api/watermark', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg), }).catch(() => {}); }; // ── Delete files ────────────────────────────────────────────────────────── const deleteFiles = async (names) => { const results = await Promise.all(names.map(async name => { try { const r = await fetch(`/api/files/${encodeURIComponent(name)}`, { method: 'DELETE' }); return { name, ok: r.ok, status: r.status }; } catch { return { name, ok: false, status: 0 }; } })); const deleted = results.filter(r => r.ok).map(r => r.name); const failed = results.filter(r => !r.ok).map(r => r.name); if (deleted.length > 0) { setFiles(fs => fs.filter(f => !deleted.includes(f.filename))); setSelected(s => { const n = new Set(s); deleted.forEach(x => n.delete(x)); return n; }); } if (failed.length > 0) { addToast(`Could not delete ${failed.length} file(s) — server permission error. Ask your admin to fix upload directory permissions.`); } }; // ── EXIF lazy loading ───────────────────────────────────────────────────── const loadExif = React.useCallback((filename) => { if (exifCache[filename] !== undefined) return; setExifCache(c => ({ ...c, [filename]: null })); fetch(`/api/exif/${encodeURIComponent(filename)}`) .then(r => r.ok ? r.json() : null) .then(data => setExifCache(c => ({ ...c, [filename]: data || {} }))) .catch(() => setExifCache(c => ({ ...c, [filename]: {} }))); }, [exifCache]); // ── Keyboard shortcuts ──────────────────────────────────────────────────── React.useEffect(() => { const gridCols = density === 'tight' ? 6 : density === 'loose' ? 3 : 4; const handler = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; if (lightboxIdx !== null) return; const file = visibleFiles[focused]; if (e.key === 'Enter' && file) { setLightboxIdx(focused); e.preventDefault(); } if ((e.key === 'p' || e.key === 'P') && file) { actions.pick(file.filename); e.preventDefault(); } if ((e.key === 'x' || e.key === 'X') && file) { actions.reject(file.filename); e.preventDefault(); } if (e.key >= '1' && e.key <= '5' && file) { actions.rate(file.filename, parseInt(e.key)); e.preventDefault(); } if (e.key === '0' && file) { actions.rate(file.filename, 0); e.preventDefault(); } if (e.key === 'ArrowRight') { setFocused(i => Math.min(i + 1, visibleFiles.length - 1)); e.preventDefault(); } if (e.key === 'ArrowLeft') { setFocused(i => Math.max(0, i - 1)); e.preventDefault(); } if (e.key === 'ArrowDown') { setFocused(i => Math.min(i + gridCols, visibleFiles.length - 1)); e.preventDefault(); } if (e.key === 'ArrowUp') { setFocused(i => Math.max(0, i - gridCols)); e.preventDefault(); } if ((e.metaKey || e.ctrlKey) && e.key === 'a') { setSelected(new Set(visibleFiles.map(f => f.filename))); e.preventDefault(); } if (e.key === 'Escape') setSelected(new Set()); if (e.key === '?') setShortcutsOpen(true); if (e.key === 'g') setView('grid'); if (e.key === 'l') setView('list'); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [visibleFiles, focused, lightboxIdx, actions, density]); // ── Broadcast to second screen ──────────────────────────────────────────── // On lightbox nav — immediate React.useEffect(() => { if (lightboxIdx === null) return; const file = visibleFiles[lightboxIdx]; if (file) broadcastPhoto(file); }, [lightboxIdx]); // eslint-disable-line // On grid focus change — debounced so fast keyboard nav doesn't flood React.useEffect(() => { const t = setTimeout(() => { if (focused == null || lightboxIdx !== null) return; const file = visibleFiles[focused]; if (file) broadcastPhoto(file); }, 280); return () => clearTimeout(t); }, [focused]); // eslint-disable-line // ── Load saved edits from server when opening lightbox ─────────────────── React.useEffect(() => { if (lightboxIdx === null) return; const file = visibleFiles[lightboxIdx]; if (!file) return; // Only fetch if we have no local edits stored for this file yet if (edits[file.filename] !== undefined) return; fetch(`/api/edit/${encodeURIComponent(file.filename)}`) .then(r => r.ok ? r.json() : null) .then(data => { if (data) setEdits(e => ({ ...e, [file.filename]: data })); }) .catch(() => {}); }, [lightboxIdx]); // eslint-disable-line react-hooks/exhaustive-deps // ── Loading state ───────────────────────────────────────────────────────── if (authed === null) { return (
LOADING…
); } // ── Public share view (route /share/:token) — no auth required ────────── const shareToken = window.location.pathname.match(/^\/share\/([^/]+)/)?.[1]; if (shareToken) { return ; } // ── Login gate ──────────────────────────────────────────────────────────── if (!authed) return ; // ── Internal share preview ──────────────────────────────────────────────── if (shareViewOpen) { return ( f.pick)} share={{ label: 'Live Session — Picks preview' }} onOpen={(i) => { setShareViewOpen(false); setLightboxIdx(i); }} onClose={() => setShareViewOpen(false)} onFeedback={(name) => setFeedback(s => { const n = new Set(s); if (n.has(name)) n.delete(name); else n.add(name); return n; })} feedback={feedback} /> ); } const currentFile = lightboxIdx !== null ? visibleFiles[lightboxIdx] : null; const currentEdits = currentFile ? (edits[currentFile.filename] || {}) : {}; if (currentFile && !exifCache[currentFile.filename] && exifCache[currentFile.filename] !== null) { loadExif(currentFile.filename); } // ── Main dashboard ──────────────────────────────────────────────────────── return (
setCollectionsDrawerOpen(false)} onNew={() => { setCollectionsDrawerOpen(false); setNewCollectionOpen(true); }} moodboards={moodboards} /> setEventModalOpen(true)} onActivateEvent={activateEvent} onDeleteEvent={deleteEvent} collections={collections} onDeleteCollection={deleteCollection} activeCollection={activeCollection} setActiveCollection={setActiveCollection} tagFilter={tagFilter} setTagFilter={setTagFilter} ratingFilter={ratingFilter} setRatingFilter={setRatingFilter} flagFilter={flagFilter} setFlagFilter={setFlagFilter} fileTypeFilter={fileTypeFilter} setFileTypeFilter={setFileTypeFilter} tagCounts={tagCounts} stats={stats} onNewCollection={() => setNewCollectionOpen(true)} shareCount={shares.length} onOpenShares={() => setShareOpen(true)} onOpenWatermark={() => setWatermarkOpen(true)} onOpenShortcuts={() => setShortcutsOpen(true)} onOpenBranding={() => setShowBranding(true)} displayName={displayName} profilePhotoUrl={profilePhotoUrl} onOpenProfile={() => setShowProfilePhoto(true)} onLogout={handleLogout} />
setSelected(new Set(visibleFiles.map(f => f.filename)))} onClearSelect={() => setSelected(new Set())} onBulkPick={() => bulkApply({ pick: true, reject: false })} onBulkReject={() => bulkApply({ reject: true, pick: false })} onBulkShare={() => setShareOpen(true)} onBulkDownload={() => { const names = Array.from(selected); fetch('/api/download-zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: names }), }) .then(r => r.ok ? r.blob() : null) .then(blob => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gallery_selection.zip'; a.click(); URL.revokeObjectURL(url); }) .catch(() => {}); }} onBulkDelete={() => deleteFiles(Array.from(selected))} onSearch={setSearchQuery} searchQuery={searchQuery} stats={stats} capturing={capturing} lastCapture={lastCapture} analyticsOpen={analyticsOpen} setAnalyticsOpen={setAnalyticsOpen} onToggleSidebar={() => setSidebarOpen(o => !o)} onOpenCollections={() => setCollectionsDrawerOpen(true)} onShare={() => setShareOpen(true)} onShareView={() => setShareViewOpen(true)} onOpenShortcuts={() => setShortcutsOpen(true)} onSecondScreen={openSecondScreen} /> { const names = Array.from(selected); fetch('/api/download-zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: names }), }).then(r => r.ok ? r.blob() : null).then(blob => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gallery_selection.zip'; a.click(); URL.revokeObjectURL(url); }).catch(() => {}); }} onDelSel={() => deleteFiles(Array.from(selected))} totalShown={visibleFiles.length} activeTag={tagFilter} setActiveTag={setTagFilter} colorFilter={colorFilter} setColorFilter={setColorFilter} /> {/* Active filter chips — sidebar filters still shown as chips */} {(flagFilter !== 'all' || ratingFilter > 0 || fileTypeFilter !== 'all' || activeCollection) && (
Sidebar filters {activeCollection && setActiveCollection(null)}> {activeCollection === '_picks' ? 'Picks' : activeCollection === '_rejected' ? 'Rejected' : activeCollection === '_rated' ? 'Rated 3★+' : activeCollection === '_blur' ? 'Soft focus' : `Collection: ${activeCollection}`} } {flagFilter !== 'all' && setFlagFilter('all')}>{flagFilter}} {ratingFilter > 0 && setRatingFilter(0)}>≥ {ratingFilter}★} {fileTypeFilter !== 'all' && setFileTypeFilter('all')}>{fileTypeFilter}}
)} {/* Gallery area */}
setSelected(new Set())}>
e.stopPropagation()}> {loading ? (
Loading gallery…
) : visibleFiles.length === 0 ? ( { setTagFilter(null); setRatingFilter(0); setFlagFilter('all'); setFileTypeFilter('all'); setActiveCollection(null); setSearchQuery(''); }}/> ) : view === 'grid' ? ( ) : ( )}
{/* Status bar — desktop only */}
{visibleFiles.length} / {files.length} files {storageInfo && (() => { const usedMB = (storageInfo.uploads_size / (1024*1024)).toFixed(0); const quotaMB = storageInfo.quota_mb; if (quotaMB) { const pct = Math.min(100, Math.round((storageInfo.uploads_size / (quotaMB * 1024 * 1024)) * 100)); return <> · 90 ? 'var(--reject)' : pct > 70 ? '#ffb27a' : 'var(--ink-3)' }}> {usedMB} MB / {quotaMB} MB 90 ? 'var(--reject)' : pct > 70 ? '#ffb27a' : 'var(--accent)', borderRadius: 2 }}/> ; } return <>·{usedMB} MB used; })()}
Auto-saved · · · ·
{/* Lightbox */} {currentFile && ( { setLightboxIdx(null); setZoom(false); setBaMode(false); setDevOpen(false); setShowExif(false); }} onNav={(d) => setLightboxIdx(i => Math.max(0, Math.min(visibleFiles.length - 1, i + d)))} onPick={() => actions.pick(currentFile.filename)} onReject={() => actions.reject(currentFile.filename)} onRate={(r) => actions.rate(currentFile.filename, r)} onColor={(c) => actions.color(currentFile.filename, c)} devOpen={devOpen} setDevOpen={setDevOpen} edits={currentEdits} onEditChange={(k, v) => setEdits(e => ({ ...e, [currentFile.filename]: { ...(e[currentFile.filename]||{}), [k]: v }}))} onEditReset={(k) => setEdits(e => ({ ...e, [currentFile.filename]: { ...(e[currentFile.filename]||{}), [k]: 0 }}))} showExif={showExif} setShowExif={setShowExif} baMode={baMode} setBaMode={setBaMode} zoom={zoom} setZoom={setZoom} /> )} {/* Develop panel */} {currentFile && ( setEdits(e => ({ ...e, [currentFile.filename]: { ...(e[currentFile.filename]||{}), [k]: v }}))} onReset={(k) => setEdits(e => ({ ...e, [currentFile.filename]: { ...(e[currentFile.filename]||{}), [k]: 0 }}))} onResetAll={() => { setEdits(e => ({ ...e, [currentFile.filename]: {} })); fetch(`/api/edit/${encodeURIComponent(currentFile.filename)}/reset`, { method: 'POST' }).catch(() => {}); }} onSave={() => fetch(`/api/edit/${encodeURIComponent(currentFile.filename)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(currentEdits), }).catch(() => {})} onApplyToAll={() => {}} /> )} {/* Modals */} {shareOpen && ( setShareOpen(false)} shares={shares} collections={collections} onCreate={createShare} onRevoke={revokeShare} /> )} {watermarkOpen && ( setWatermarkOpen(false)} config={watermarkCfg} onSave={saveWatermark} /> )} {newCollectionOpen && ( setNewCollectionOpen(false)} onCreate={createCollection} /> )} {shortcutsOpen && setShortcutsOpen(false)}/>} {eventModalOpen && ( setEventModalOpen(false)} onCreate={async (data) => { await createEvent(data); setEventModalOpen(false); }} /> )} {/* Welcome / onboarding */} {showWelcome && setShowWelcome(false)}/>} {/* Branding */} {showBranding && setShowBranding(false)}/>} {/* Profile photo */} {showProfilePhoto && setShowProfilePhoto(false)} onSaved={() => setProfilePhotoUrl(`/api/profile/photo?t=${Date.now()}`)} />} {/* Toast notifications */} {toasts.length > 0 && (
{toasts.map(t => (
{t.msg}
))}
)}
); } // ── Filter chip ─────────────────────────────────────────────────────────────── function FilterChip({ children, onRemove }) { return ( {children} ); } // ── Empty state ─────────────────────────────────────────────────────────────── function EmptyState({ clearAll }) { return (
No photos match
Adjust your filters or clear them to see everything.
Clear all filters
); } // ── Bootstrap ────────────────────────────────────────────────────────────────── ReactDOM.createRoot(document.getElementById('root')).render();