feat: PWA + mobile-friendly layout
- Add vite-plugin-pwa: generates manifest, Workbox service worker, precaches app shell - Generate icon-192, icon-512, apple-touch-icon PNGs from existing SVG logo - Add theme-color, apple-mobile-web-app-capable, apple-touch-icon meta tags; viewport-fit=cover for notch support - Mobile layout (< 768px): bottom tab bar (Library, Albums, Memories, Map, More) replaces top nav; sidebar becomes slide-in filter drawer with backdrop; bulk action bar clears bottom nav; safe-area insets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc2eb7307f
commit
93e16561ec
8 files changed
+5440
-39
No files matched your search
+7
-1
@@ -2,8 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f0f0f" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Luma" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<title>Luma</title>
|
<title>Luma</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+217
-35
@@ -19,7 +19,20 @@ const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId:
|
|||||||
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
|
||||||
const SIDEBAR_W = 210;
|
const SIDEBAR_W = 210;
|
||||||
|
|
||||||
|
function useMobile() {
|
||||||
|
const [mobile, setMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)');
|
||||||
|
const handler = e => setMobile(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const isMobile = useMobile();
|
||||||
|
|
||||||
const [view, setView] = useState('library');
|
const [view, setView] = useState('library');
|
||||||
const [photos, setPhotos] = useState([]);
|
const [photos, setPhotos] = useState([]);
|
||||||
const [nextCursor, setNextCursor] = useState(undefined);
|
const [nextCursor, setNextCursor] = useState(undefined);
|
||||||
@@ -37,10 +50,15 @@ export default function App() {
|
|||||||
const [lbIndex, setLbIndex] = useState(null);
|
const [lbIndex, setLbIndex] = useState(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [selected, setSelected] = useState(new Set());
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
|
// Close mobile sidebar when switching to desktop
|
||||||
|
useEffect(() => { if (!isMobile) setSidebarOpen(false); }, [isMobile]);
|
||||||
|
|
||||||
const loadStats = useCallback(() => {
|
const loadStats = useCallback(() => {
|
||||||
fetchLibraryStats().then(setStats).catch(() => {});
|
fetchLibraryStats().then(setStats).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -53,7 +71,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [view]);
|
}, [view]);
|
||||||
|
|
||||||
// Reset + reload when filters change (debounce text search)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delay = filters.q ? 300 : 0;
|
const delay = filters.q ? 300 : 0;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -81,7 +98,6 @@ export default function App() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infinite scroll sentinel
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sentinelRef.current || !nextCursor || view !== 'library') return;
|
if (!sentinelRef.current || !nextCursor || view !== 'library') return;
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
@@ -157,16 +173,14 @@ export default function App() {
|
|||||||
setLbIndex(idx);
|
setLbIndex(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a photo's star state changes in the lightbox, patch it in the local list
|
|
||||||
function handleStarChange(updated) {
|
function handleStarChange(updated) {
|
||||||
setPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
setPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
||||||
if (lbPhotos) {
|
if (lbPhotos) {
|
||||||
setLbPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
setLbPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p));
|
||||||
}
|
}
|
||||||
loadStats(); // refresh sidebar count
|
loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When ingest finishes, refresh photos + stats
|
|
||||||
function handleScanComplete() {
|
function handleScanComplete() {
|
||||||
setPhotos([]);
|
setPhotos([]);
|
||||||
setNextCursor(undefined);
|
setNextCursor(undefined);
|
||||||
@@ -174,7 +188,15 @@ export default function App() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeView(v) {
|
||||||
|
setView(v);
|
||||||
|
clearSelection();
|
||||||
|
setMoreOpen(false);
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city || filters.importedFrom;
|
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city || filters.importedFrom;
|
||||||
|
const hasSidebar = view === 'library' || view === 'videos';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -182,15 +204,17 @@ export default function App() {
|
|||||||
<header style={{
|
<header style={{
|
||||||
position: 'sticky', top: 0, zIndex: 100,
|
position: 'sticky', top: 0, zIndex: 100,
|
||||||
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
|
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
|
||||||
height: 52, padding: '0 20px',
|
height: 52, padding: '0 12px',
|
||||||
display: 'flex', alignItems: 'center', gap: 0,
|
display: 'flex', alignItems: 'center', gap: 0,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#fff', marginRight: 20, letterSpacing: -0.3 }}>
|
<span style={{ fontSize: 16, fontWeight: 700, color: '#fff', marginRight: isMobile ? 'auto' : 20, letterSpacing: -0.3 }}>
|
||||||
Luma
|
Luma
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Desktop nav tabs */}
|
||||||
|
{!isMobile && (
|
||||||
<nav style={{ display: 'flex', gap: 2 }}>
|
<nav style={{ display: 'flex', gap: 2 }}>
|
||||||
<Tab active={view === 'library'} onClick={() => { setView('library'); clearSelection(); }}>
|
<Tab active={view === 'library'} onClick={() => changeView('library')}>
|
||||||
Library
|
Library
|
||||||
{total !== null && (
|
{total !== null && (
|
||||||
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
|
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
|
||||||
@@ -198,11 +222,11 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab active={view === 'albums'} onClick={() => { setView('albums'); clearSelection(); }}>Albums</Tab>
|
<Tab active={view === 'albums'} onClick={() => changeView('albums')}>Albums</Tab>
|
||||||
<Tab active={view === 'videos'} onClick={() => { setView('videos'); clearSelection(); }}>Videos</Tab>
|
<Tab active={view === 'videos'} onClick={() => changeView('videos')}>Videos</Tab>
|
||||||
<Tab active={view === 'memories'} onClick={() => { setView('memories'); clearSelection(); }}>Memories</Tab>
|
<Tab active={view === 'memories'} onClick={() => changeView('memories')}>Memories</Tab>
|
||||||
<Tab active={view === 'map'} onClick={() => { setView('map'); clearSelection(); }}>Map</Tab>
|
<Tab active={view === 'map'} onClick={() => changeView('map')}>Map</Tab>
|
||||||
<Tab active={view === 'duplicates'} onClick={() => { setView('duplicates'); clearSelection(); }}>
|
<Tab active={view === 'duplicates'} onClick={() => changeView('duplicates')}>
|
||||||
Duplicates
|
Duplicates
|
||||||
{dupeCount > 0 && (
|
{dupeCount > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -214,11 +238,34 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab active={view === 'trash'} onClick={() => { setView('trash'); clearSelection(); }}>Trash</Tab>
|
<Tab active={view === 'trash'} onClick={() => changeView('trash')}>Trash</Tab>
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: filter button (sidebar toggle) */}
|
||||||
|
{isMobile && hasSidebar && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(s => !s)}
|
||||||
|
title="Filters"
|
||||||
|
style={{
|
||||||
|
background: sidebarOpen ? '#1e2a3a' : 'transparent',
|
||||||
|
border: 'none', borderRadius: 6,
|
||||||
|
color: sidebarOpen ? '#7ab8f5' : '#888',
|
||||||
|
cursor: 'pointer', fontSize: 18,
|
||||||
|
width: 38, height: 38,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⊟
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right side: ingest badge + gear */}
|
{/* Right side: ingest badge + gear */}
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10 }}>
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8,
|
||||||
|
...(isMobile ? {} : { flex: 1 }),
|
||||||
|
}}>
|
||||||
<IngestBadge onScanComplete={handleScanComplete} />
|
<IngestBadge onScanComplete={handleScanComplete} />
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(s => !s)}
|
onClick={() => setShowSettings(s => !s)}
|
||||||
@@ -240,18 +287,34 @@ export default function App() {
|
|||||||
{/* ── Body (sidebar + content) ── */}
|
{/* ── Body (sidebar + content) ── */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||||
|
|
||||||
{/* Sidebar — library and videos views */}
|
{/* Mobile sidebar backdrop */}
|
||||||
{(view === 'library' || view === 'videos') && (
|
{isMobile && sidebarOpen && (
|
||||||
|
<div
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 200 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
{hasSidebar && (
|
||||||
<aside style={{
|
<aside style={{
|
||||||
position: 'sticky', top: 52,
|
position: isMobile ? 'fixed' : 'sticky',
|
||||||
width: SIDEBAR_W, flexShrink: 0,
|
top: isMobile ? 0 : 52,
|
||||||
height: 'calc(100vh - 52px)', overflowY: 'auto',
|
left: 0,
|
||||||
borderRight: '1px solid #141414', background: '#0a0a0a',
|
width: isMobile ? 280 : SIDEBAR_W,
|
||||||
|
height: isMobile ? '100dvh' : 'calc(100vh - 52px)',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
borderRight: '1px solid #141414',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
zIndex: isMobile ? 201 : undefined,
|
||||||
|
transform: isMobile ? (sidebarOpen ? 'translateX(0)' : 'translateX(-100%)') : undefined,
|
||||||
|
transition: isMobile ? 'transform 0.22s ease' : undefined,
|
||||||
}}>
|
}}>
|
||||||
{view === 'library' && (
|
{view === 'library' && (
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={f => { setFilters(f); if (isMobile) setSidebarOpen(false); }}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
total={total}
|
total={total}
|
||||||
/>
|
/>
|
||||||
@@ -259,7 +322,7 @@ export default function App() {
|
|||||||
{view === 'videos' && (
|
{view === 'videos' && (
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
filters={videoFilters}
|
filters={videoFilters}
|
||||||
onChange={setVideoFilters}
|
onChange={f => { setVideoFilters(f); if (isMobile) setSidebarOpen(false); }}
|
||||||
stats={videoStats}
|
stats={videoStats}
|
||||||
total={videoStats?.total}
|
total={videoStats?.total}
|
||||||
allLabel="All Videos"
|
allLabel="All Videos"
|
||||||
@@ -269,9 +332,11 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
paddingBottom: isMobile ? 'calc(56px + env(safe-area-inset-bottom, 0px))' : 0,
|
||||||
|
}}>
|
||||||
|
|
||||||
{/* Library */}
|
|
||||||
{view === 'library' && (
|
{view === 'library' && (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -308,35 +373,30 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Albums */}
|
|
||||||
{view === 'albums' && (
|
{view === 'albums' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
<AlbumsView onSelectAlbum={handleSelectAlbum} />
|
<AlbumsView onSelectAlbum={handleSelectAlbum} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Videos */}
|
|
||||||
{view === 'videos' && (
|
{view === 'videos' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
<VideosView filters={videoFilters} />
|
<VideosView filters={videoFilters} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Memories */}
|
|
||||||
{view === 'memories' && (
|
{view === 'memories' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
<MemoriesView />
|
<MemoriesView />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Map */}
|
|
||||||
{view === 'map' && (
|
{view === 'map' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading map…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading map…</p>}>
|
||||||
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
|
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Duplicates */}
|
|
||||||
{view === 'duplicates' && (
|
{view === 'duplicates' && (
|
||||||
<DuplicatesView
|
<DuplicatesView
|
||||||
onGroupCountChange={setDupeCount}
|
onGroupCountChange={setDupeCount}
|
||||||
@@ -346,7 +406,6 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trash */}
|
|
||||||
{view === 'trash' && (
|
{view === 'trash' && (
|
||||||
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
|
||||||
<TrashView />
|
<TrashView />
|
||||||
@@ -355,19 +414,21 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline scrubber — right-edge year nav for library */}
|
{/* Timeline scrubber — desktop only */}
|
||||||
{view === 'library' && (
|
{view === 'library' && !isMobile && (
|
||||||
<TimelineScrubber years={stats?.years ?? []} />
|
<TimelineScrubber years={stats?.years ?? []} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selected.size > 0 && view === 'library' && (
|
{selected.size > 0 && view === 'library' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: isMobile ? 'calc(56px + env(safe-area-inset-bottom, 8px) + 8px)' : 20,
|
||||||
|
left: '50%', transform: 'translateX(-50%)',
|
||||||
background: '#1c1c1c', border: '1px solid #2e2e2e', borderRadius: 12,
|
background: '#1c1c1c', border: '1px solid #2e2e2e', borderRadius: 12,
|
||||||
padding: '9px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
padding: '9px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.7)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.7)',
|
||||||
zIndex: 500, whiteSpace: 'nowrap',
|
zIndex: 500, whiteSpace: 'nowrap',
|
||||||
|
maxWidth: 'calc(100vw - 32px)', overflowX: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 13, color: '#ccc', fontWeight: 600 }}>
|
<span style={{ fontSize: 13, color: '#ccc', fontWeight: 600 }}>
|
||||||
{selected.size} selected
|
{selected.size} selected
|
||||||
@@ -395,6 +456,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile bottom nav */}
|
||||||
|
{isMobile && (
|
||||||
|
<BottomNav
|
||||||
|
view={view}
|
||||||
|
onNavigate={changeView}
|
||||||
|
moreOpen={moreOpen}
|
||||||
|
setMoreOpen={setMoreOpen}
|
||||||
|
dupeCount={dupeCount}
|
||||||
|
total={total}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings panel */}
|
{/* Settings panel */}
|
||||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||||
|
|
||||||
@@ -415,7 +488,7 @@ export default function App() {
|
|||||||
|
|
||||||
const bulkBtn = {
|
const bulkBtn = {
|
||||||
background: 'transparent', border: '1px solid #333', borderRadius: 6,
|
background: 'transparent', border: '1px solid #333', borderRadius: 6,
|
||||||
color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 11px',
|
color: '#ccc', cursor: 'pointer', fontSize: 12, padding: '5px 11px', flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Tab({ active, onClick, children }) {
|
function Tab({ active, onClick, children }) {
|
||||||
@@ -435,3 +508,112 @@ function Tab({ active, onClick, children }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOTTOM_TABS = [
|
||||||
|
{ id: 'library', icon: '⊞', label: 'Library' },
|
||||||
|
{ id: 'albums', icon: '◫', label: 'Albums' },
|
||||||
|
{ id: 'memories', icon: '✦', label: 'Memories' },
|
||||||
|
{ id: 'map', icon: '◉', label: 'Map' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MORE_ITEMS = [
|
||||||
|
{ id: 'videos', label: 'Videos' },
|
||||||
|
{ id: 'duplicates', label: 'Duplicates' },
|
||||||
|
{ id: 'trash', label: 'Trash' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function BottomNav({ view, onNavigate, moreOpen, setMoreOpen, dupeCount, total }) {
|
||||||
|
const isMore = MORE_ITEMS.some(t => t.id === view);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop closes the "more" popup */}
|
||||||
|
{moreOpen && (
|
||||||
|
<div
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 298 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav style={{
|
||||||
|
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 300,
|
||||||
|
background: '#101010', borderTop: '1px solid #1e1e1e',
|
||||||
|
display: 'flex', alignItems: 'stretch',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
}}>
|
||||||
|
{BOTTOM_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onNavigate(tab.id)}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: 'transparent', border: 'none',
|
||||||
|
color: view === tab.id ? '#4d9eff' : '#555',
|
||||||
|
cursor: 'pointer', padding: '8px 4px 6px',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||||
|
fontSize: 10, fontWeight: view === tab.id ? 600 : 400,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20, lineHeight: 1 }}>{tab.icon}</span>
|
||||||
|
{tab.id === 'library' && total !== null
|
||||||
|
? `Library (${total.toLocaleString()})`
|
||||||
|
: tab.label
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* More */}
|
||||||
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreOpen(s => !s)}
|
||||||
|
style={{
|
||||||
|
width: '100%', height: '100%', background: 'transparent', border: 'none',
|
||||||
|
color: isMore ? '#4d9eff' : '#555',
|
||||||
|
cursor: 'pointer', padding: '8px 4px 6px',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||||
|
fontSize: 10, fontWeight: isMore ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20, lineHeight: 1, letterSpacing: -1 }}>···</span>
|
||||||
|
{isMore ? (view.charAt(0).toUpperCase() + view.slice(1)) : 'More'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{moreOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 'calc(100% + 6px)', right: 6,
|
||||||
|
background: '#1c1c1c', border: '1px solid #2e2e2e',
|
||||||
|
borderRadius: 10, overflow: 'hidden', minWidth: 150,
|
||||||
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.7)',
|
||||||
|
zIndex: 299,
|
||||||
|
}}>
|
||||||
|
{MORE_ITEMS.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
width: '100%', textAlign: 'left',
|
||||||
|
background: view === item.id ? '#222' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: i < MORE_ITEMS.length - 1 ? '1px solid #242424' : 'none',
|
||||||
|
color: view === item.id ? '#4d9eff' : '#ccc',
|
||||||
|
cursor: 'pointer', padding: '13px 16px', fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{item.id === 'duplicates' && dupeCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
background: '#b8860b', color: '#fff',
|
||||||
|
borderRadius: 10, fontSize: 10, fontWeight: 700,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}>{dupeCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+5181
-1
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"vite": "^8.2.1"
|
"vite": "^8.2.1",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
-1
@@ -1,9 +1,41 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: 'client',
|
root: 'client',
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg', 'icons/apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Luma',
|
||||||
|
short_name: 'Luma',
|
||||||
|
description: 'Self-hosted photo manager',
|
||||||
|
theme_color: '#0f0f0f',
|
||||||
|
background_color: '#0f0f0f',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^\/api\//,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in new issue
Block a user