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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 17:03:30 -04:00
1 parent bc2eb7307f
commit 93e16561ec
8 files changed
+5463 -62

No files matched your search

+7 -1
View File
@@ -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

+240 -58
View File
@@ -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);
@@ -28,19 +41,24 @@ export default function App() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [dupeCount, setDupeCount] = useState(0); const [dupeCount, setDupeCount] = useState(0);
const [filters, setFilters] = useState(EMPTY_FILTERS); const [filters, setFilters] = useState(EMPTY_FILTERS);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS); const [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS);
const [videoStats, setVideoStats] = useState(null); const [videoStats, setVideoStats] = useState(null);
const [lbPhotos, setLbPhotos] = useState(null); const [lbPhotos, setLbPhotos] = useState(null);
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,43 +204,68 @@ 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>
<nav style={{ display: 'flex', gap: 2 }}> {/* Desktop nav tabs */}
<Tab active={view === 'library'} onClick={() => { setView('library'); clearSelection(); }}> {!isMobile && (
Library <nav style={{ display: 'flex', gap: 2 }}>
{total !== null && ( <Tab active={view === 'library'} onClick={() => changeView('library')}>
<span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}> Library
{total.toLocaleString()} {total !== null && (
</span> <span style={{ marginLeft: 6, fontSize: 12, color: view === 'library' ? '#777' : '#666' }}>
)} {total.toLocaleString()}
</Tab> </span>
<Tab active={view === 'albums'} onClick={() => { setView('albums'); clearSelection(); }}>Albums</Tab> )}
<Tab active={view === 'videos'} onClick={() => { setView('videos'); clearSelection(); }}>Videos</Tab> </Tab>
<Tab active={view === 'memories'} onClick={() => { setView('memories'); clearSelection(); }}>Memories</Tab> <Tab active={view === 'albums'} onClick={() => changeView('albums')}>Albums</Tab>
<Tab active={view === 'map'} onClick={() => { setView('map'); clearSelection(); }}>Map</Tab> <Tab active={view === 'videos'} onClick={() => changeView('videos')}>Videos</Tab>
<Tab active={view === 'duplicates'} onClick={() => { setView('duplicates'); clearSelection(); }}> <Tab active={view === 'memories'} onClick={() => changeView('memories')}>Memories</Tab>
Duplicates <Tab active={view === 'map'} onClick={() => changeView('map')}>Map</Tab>
{dupeCount > 0 && ( <Tab active={view === 'duplicates'} onClick={() => changeView('duplicates')}>
<span style={{ Duplicates
marginLeft: 6, background: '#b8860b', color: '#fff', {dupeCount > 0 && (
borderRadius: 10, fontSize: 10, fontWeight: 700, <span style={{
padding: '1px 6px', lineHeight: '16px', marginLeft: 6, background: '#b8860b', color: '#fff',
}}> borderRadius: 10, fontSize: 10, fontWeight: 700,
{dupeCount} padding: '1px 6px', lineHeight: '16px',
</span> }}>
)} {dupeCount}
</Tab> </span>
<Tab active={view === 'trash'} onClick={() => { setView('trash'); clearSelection(); }}>Trash</Tab> )}
</nav> </Tab>
<Tab active={view === 'trash'} onClick={() => changeView('trash')}>Trash</Tab>
</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>
</>
);
}
+5181 -1
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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
View File
@@ -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: {