From e0f4e8ff0d80cf9cd758f26defdc71b8a54c2ac8 Mon Sep 17 00:00:00 2001
From: nmemmert <67961292+nmemmert@users.noreply.github.com>
Date: Mon, 17 Aug 2026 16:33:55 -0400
Subject: [PATCH] feat: initial Luma implementation
Full self-hosted photo manager with:
- Ingestion worker (chokidar + sharp + exifr, dHash perceptual hashing)
- SQLite schema with WAL mode (photos, tags, collections, duplicate_pairs)
- Express API with keyset pagination, tag/collection/search/date filters
- React + Vite UI: timeline, lightbox, detail panel, tag/collection editors
- Duplicate detection (Union-Find grouping) with review + dismiss UI
- Map view (Leaflet + OpenStreetMap, marker clustering)
- Docker multi-stage build + Compose with inline env config
- GitHub Actions: CI build check + ghcr.io image publish on main/tags
Co-Authored-By: Claude Sonnet 4.6
---
.dockerignore | 9 +
.env.example | 9 +
.github/workflows/ci.yml | 26 +
.github/workflows/docker.yml | 50 +
.gitignore | 2 +
Dockerfile | 40 +
client/index.html | 12 +
client/src/App.jsx | 209 ++
client/src/api/photos.js | 74 +
client/src/components/CollectionEditor.jsx | 99 +
client/src/components/DetailPanel.jsx | 110 +
client/src/components/DuplicateGroup.jsx | 100 +
client/src/components/DuplicatesView.jsx | 110 +
client/src/components/FilterBar.jsx | 84 +
client/src/components/Lightbox.jsx | 145 ++
client/src/components/MapView.jsx | 104 +
client/src/components/MonthGroup.jsx | 36 +
client/src/components/PhotoCard.jsx | 49 +
client/src/components/TagEditor.jsx | 113 +
client/src/components/Timeline.jsx | 48 +
client/src/index.css | 25 +
client/src/main.jsx | 10 +
docker-compose.yml | 39 +
package-lock.json | 2618 ++++++++++++++++++++
package.json | 32 +
server/dedupe.js | 80 +
server/index.js | 248 ++
vite.config.js | 18 +
worker/db.js | 161 ++
worker/hash.js | 31 +
worker/index.js | 60 +
worker/ingest.js | 94 +
32 files changed, 4845 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/docker.yml
create mode 100644 Dockerfile
create mode 100644 client/index.html
create mode 100644 client/src/App.jsx
create mode 100644 client/src/api/photos.js
create mode 100644 client/src/components/CollectionEditor.jsx
create mode 100644 client/src/components/DetailPanel.jsx
create mode 100644 client/src/components/DuplicateGroup.jsx
create mode 100644 client/src/components/DuplicatesView.jsx
create mode 100644 client/src/components/FilterBar.jsx
create mode 100644 client/src/components/Lightbox.jsx
create mode 100644 client/src/components/MapView.jsx
create mode 100644 client/src/components/MonthGroup.jsx
create mode 100644 client/src/components/PhotoCard.jsx
create mode 100644 client/src/components/TagEditor.jsx
create mode 100644 client/src/components/Timeline.jsx
create mode 100644 client/src/index.css
create mode 100644 client/src/main.jsx
create mode 100644 docker-compose.yml
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 server/dedupe.js
create mode 100644 server/index.js
create mode 100644 vite.config.js
create mode 100644 worker/db.js
create mode 100644 worker/hash.js
create mode 100644 worker/index.js
create mode 100644 worker/ingest.js
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b63b66b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+data
+.env
+.env.*
+*.log
+.git
+.github
+photos
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0498bc8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# Path to your photo library (read-only mount in production)
+PHOTO_DIR=./photos
+
+# Where the SQLite DB and generated posters live (writable volume)
+DB_PATH=./data/library.db
+POSTER_DIR=./data/posters
+
+# API server port
+PORT=3000
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..da23647
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,26 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ name: Build & test
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build client
+ run: npm run build
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..1975a0f
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,50 @@
+name: Docker
+
+on:
+ push:
+ branches: [main]
+ tags: ['v*.*.*']
+
+jobs:
+ build-push:
+ name: Build & push image
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=sha-
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 27bdffb..8419bf8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
node_modules/
dist/
.env
+data/
+photos/
*.db
*.db-shm
*.db-wal
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ffb1c46
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+# ── Stage 1: build client ────────────────────────────────────────────────────
+FROM node:22-alpine AS builder
+
+# Build tools needed by better-sqlite3 native addon
+RUN apk add --no-cache python3 make g++
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# ── Stage 2: runtime ─────────────────────────────────────────────────────────
+FROM node:22-alpine
+
+RUN apk add --no-cache python3 make g++
+
+WORKDIR /app
+
+COPY package*.json ./
+# Reinstall in the runtime image so native addons compile for this arch
+RUN npm ci --omit=dev
+
+COPY --from=builder /app/dist ./dist
+COPY worker ./worker
+COPY server ./server
+
+ENV PORT=3000 \
+ DB_PATH=/data/library.db \
+ POSTER_DIR=/data/posters \
+ PHOTO_DIR=/photos \
+ NODE_ENV=production
+
+EXPOSE 3000
+
+# /data — SQLite database + generated thumbnails (mount a named volume)
+# /photos — read-only source photo directory (bind-mount from host)
+VOLUME ["/data", "/photos"]
+
+CMD ["node", "server/index.js"]
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..2a98c1d
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ shpm
+
+
+
+
+
+
diff --git a/client/src/App.jsx b/client/src/App.jsx
new file mode 100644
index 0000000..a8e9016
--- /dev/null
+++ b/client/src/App.jsx
@@ -0,0 +1,209 @@
+import { useState, useEffect, useRef } from 'react';
+import { fetchPhotos, fetchTags, fetchCollections } from './api/photos.js';
+import Timeline from './components/Timeline.jsx';
+import Lightbox from './components/Lightbox.jsx';
+import FilterBar from './components/FilterBar.jsx';
+import DuplicatesView from './components/DuplicatesView.jsx';
+
+// Lazy-load MapView so Leaflet CSS doesn't bloat the initial bundle
+import { lazy, Suspense } from 'react';
+const MapView = lazy(() => import('./components/MapView.jsx'));
+
+const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '' };
+
+export default function App() {
+ const [view, setView] = useState('library');
+ const [photos, setPhotos] = useState([]);
+ const [nextCursor, setNextCursor] = useState(undefined);
+ const [loading, setLoading] = useState(false);
+ const [total, setTotal] = useState(null);
+ const [error, setError] = useState(null);
+ const [dupeCount, setDupeCount] = useState(0);
+
+ // Filter state
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
+ const [tags, setTags] = useState([]);
+ const [collections, setCollections] = useState([]);
+
+ // Unified lightbox
+ const [lbPhotos, setLbPhotos] = useState(null);
+ const [lbIndex, setLbIndex] = useState(null);
+
+ const loadingRef = useRef(false);
+ const sentinelRef = useRef(null);
+
+ // Fetch dropdown data once
+ useEffect(() => {
+ fetchTags().then(setTags).catch(() => {});
+ fetchCollections().then(setCollections).catch(() => {});
+ }, []);
+
+ // Reset + reload when filters change (debounce text search)
+ useEffect(() => {
+ const delay = filters.q ? 300 : 0;
+ const timer = setTimeout(() => {
+ setPhotos([]);
+ setNextCursor(undefined);
+ loadPage(undefined, filters);
+ }, delay);
+ return () => clearTimeout(timer);
+ }, [filters]);
+
+ async function loadPage(cursor, activeFilters) {
+ if (loadingRef.current) return;
+ loadingRef.current = true;
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await fetchPhotos({ cursor, ...activeFilters });
+ setPhotos(prev => cursor !== undefined ? [...prev, ...data.photos] : data.photos);
+ setNextCursor(data.nextCursor ?? null);
+ setTotal(data.total);
+ } catch (e) {
+ setError(e.message);
+ }
+ loadingRef.current = false;
+ setLoading(false);
+ }
+
+ // Infinite scroll sentinel
+ useEffect(() => {
+ if (!sentinelRef.current || !nextCursor || view !== 'library') return;
+ const el = sentinelRef.current;
+ const cur = nextCursor;
+ const obs = new IntersectionObserver(
+ ([entry]) => { if (entry.isIntersecting) loadPage(cur, filters); },
+ { rootMargin: '600px' }
+ );
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, [nextCursor, view, filters]);
+
+ function openLightbox(photosArr, idx) {
+ setLbPhotos(photosArr);
+ setLbIndex(idx);
+ }
+
+ return (
+ <>
+
+
+ Luma
+
+
+
+
+
+ {/* ── Library ── */}
+ {view === 'library' && (
+ <>
+
+ {error && (
+
+ {error}
+
+ )}
+ {photos.length === 0 && !loading && !error && (
+
+ {filters.q || filters.from || filters.to || filters.tagId || filters.collectionId ? (
+
No photos match these filters.
+ ) : (
+ <>
+
No photos indexed yet.
+
Set PHOTO_DIR in .env and run npm run worker.
+ >
+ )}
+
+ )}
+ openLightbox(photos, idx)} />
+
+ {loading && Loading…
}
+ {nextCursor === null && photos.length > 0 && !loading && (
+
+ — {total?.toLocaleString()} photo{total === 1 ? '' : 's'} —
+
+ )}
+ >
+ )}
+
+ {/* ── Map ── */}
+ {view === 'map' && (
+ Loading map…
}>
+ openLightbox([photo], 0)} />
+
+ )}
+
+ {/* ── Duplicates ── */}
+ {view === 'duplicates' && (
+
+ openLightbox(group, group.findIndex(p => p.id === photo.id))
+ }
+ />
+ )}
+
+ {/* ── Lightbox (shared) ── */}
+ {lbPhotos !== null && (
+ { setLbPhotos(null); setLbIndex(null); }}
+ onChange={setLbIndex}
+ />
+ )}
+ >
+ );
+}
+
+function Tab({ active, onClick, children }) {
+ return (
+
+ );
+}
diff --git a/client/src/api/photos.js b/client/src/api/photos.js
new file mode 100644
index 0000000..c3a3a28
--- /dev/null
+++ b/client/src/api/photos.js
@@ -0,0 +1,74 @@
+async function api(path, options) {
+ const res = await fetch(path, options);
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.status === 204 ? null : res.json();
+}
+
+const json = (body) => ({
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+});
+
+// Library
+export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId } = {}) {
+ const p = new URLSearchParams({ limit });
+ if (cursor) p.set('cursor', cursor);
+ if (q) p.set('q', q);
+ if (from) p.set('from', from);
+ if (to) p.set('to', to);
+ if (tagId) p.set('tagId', tagId);
+ if (collectionId) p.set('collectionId', collectionId);
+ return api(`/api/photos?${p}`);
+}
+
+export function fetchPhotoDetail(id) {
+ return api(`/api/photos/${id}`);
+}
+
+export function fetchMapPhotos() {
+ return api('/api/photos/map');
+}
+
+// Tags
+export function addTag(photoId, name) {
+ return api(`/api/photos/${photoId}/tags`, { method: 'POST', ...json({ name }) });
+}
+
+export function removeTag(photoId, tagId) {
+ return api(`/api/photos/${photoId}/tags/${tagId}`, { method: 'DELETE' });
+}
+
+// Collections
+export function fetchCollections() {
+ return api('/api/collections');
+}
+
+export function createCollection(name, description) {
+ return api('/api/collections', { method: 'POST', ...json({ name, description }) });
+}
+
+export function addToCollection(photoId, collectionId) {
+ return api(`/api/photos/${photoId}/collections`, { method: 'POST', ...json({ collectionId }) });
+}
+
+export function removeFromCollection(photoId, collectionId) {
+ return api(`/api/photos/${photoId}/collections/${collectionId}`, { method: 'DELETE' });
+}
+
+// Tags + collections (for filter dropdowns)
+export function fetchTags() {
+ return api('/api/tags');
+}
+
+// Duplicates
+export function fetchDuplicates() {
+ return api('/api/duplicates');
+}
+
+export function scanDuplicates() {
+ return api('/api/dedupe/scan', { method: 'POST' });
+}
+
+export function dismissGroup(photoIds) {
+ return api('/api/duplicates/dismiss', { method: 'POST', ...json({ photoIds }) });
+}
diff --git a/client/src/components/CollectionEditor.jsx b/client/src/components/CollectionEditor.jsx
new file mode 100644
index 0000000..e18c92a
--- /dev/null
+++ b/client/src/components/CollectionEditor.jsx
@@ -0,0 +1,99 @@
+import { useState, useEffect } from 'react';
+import { addToCollection, removeFromCollection, fetchCollections, createCollection } from '../api/photos.js';
+import { Chip, Btn, iStyle } from './TagEditor.jsx';
+
+export default function CollectionEditor({ photoId, initialCollections }) {
+ const [collections, setCollections] = useState(initialCollections);
+ const [all, setAll] = useState([]);
+ const [mode, setMode] = useState('select'); // 'select' | 'create'
+ const [selectedId, setSelectedId] = useState('');
+ const [newName, setNewName] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ fetchCollections().then(setAll).catch(() => {});
+ }, []);
+
+ const currentIds = new Set(collections.map(c => c.id));
+ const available = all.filter(c => !currentIds.has(c.id));
+
+ async function handleAdd() {
+ if (busy) return;
+ setBusy(true);
+ try {
+ if (mode === 'select') {
+ if (!selectedId) return;
+ const col = all.find(c => c.id === parseInt(selectedId, 10));
+ await addToCollection(photoId, col.id);
+ setCollections(prev => [...prev, col]);
+ setSelectedId('');
+ } else {
+ const name = newName.trim();
+ if (!name) return;
+ const col = await createCollection(name);
+ setAll(prev => [...prev, col].sort((a, b) => a.name.localeCompare(b.name)));
+ await addToCollection(photoId, col.id);
+ setCollections(prev => [...prev, col]);
+ setNewName('');
+ setMode('select');
+ }
+ } catch {}
+ setBusy(false);
+ }
+
+ async function handleRemove(colId) {
+ setCollections(prev => prev.filter(c => c.id !== colId));
+ try {
+ await removeFromCollection(photoId, colId);
+ } catch {}
+ }
+
+ return (
+
+ {collections.length > 0 && (
+
+ {collections.map(c => (
+ handleRemove(c.id)} />
+ ))}
+
+ )}
+
+ {mode === 'select' ? (
+
+
+ {selectedId && +}
+ setMode('create')}
+ style={{ width: 'auto', padding: '0 10px', fontSize: 11 }}
+ >
+ New
+
+
+ ) : (
+
+ setNewName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setMode('select'); }}
+ placeholder="Collection name…"
+ maxLength={200}
+ style={iStyle}
+ />
+ +
+ setMode('select')}>×
+
+ )}
+
+ );
+}
diff --git a/client/src/components/DetailPanel.jsx b/client/src/components/DetailPanel.jsx
new file mode 100644
index 0000000..980dbc1
--- /dev/null
+++ b/client/src/components/DetailPanel.jsx
@@ -0,0 +1,110 @@
+import { useState, useEffect } from 'react';
+import { fetchPhotoDetail } from '../api/photos.js';
+import TagEditor from './TagEditor.jsx';
+import CollectionEditor from './CollectionEditor.jsx';
+
+export default function DetailPanel({ photoId }) {
+ const [photo, setPhoto] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setPhoto(null);
+ setLoading(true);
+ fetchPhotoDetail(photoId)
+ .then(setPhoto)
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [photoId]);
+
+ if (loading) {
+ return Loading…
;
+ }
+ if (!photo) return null;
+
+ const filename = photo.filepath?.split('/').pop();
+
+ return (
+
+
+
+ {filename &&
}
+ {photo.date_taken && (
+
+ )}
+ {photo.date_imported && (
+
+ )}
+ {photo.width && photo.height && (
+
+ )}
+ {photo.filesize && (
+
+ )}
+
+
+ {photo.camera_model && (
+
+ )}
+
+ {photo.gps_lat != null && (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function Section({ label, children }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function Row({ k, v, mono }) {
+ return (
+
+ {k}
+
+ {v}
+
+
+ );
+}
+
+function formatBytes(b) {
+ if (b < 1024) return `${b} B`;
+ if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`;
+ return `${(b / 1048576).toFixed(1)} MB`;
+}
+
+const panelPad = { padding: '14px 16px' };
diff --git a/client/src/components/DuplicateGroup.jsx b/client/src/components/DuplicateGroup.jsx
new file mode 100644
index 0000000..9977f56
--- /dev/null
+++ b/client/src/components/DuplicateGroup.jsx
@@ -0,0 +1,100 @@
+function fmtSize(b) {
+ if (!b) return null;
+ if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
+ return `${(b / 1048576).toFixed(1)} MB`;
+}
+
+function fmtDate(d) {
+ if (!d) return 'Undated';
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+export default function DuplicateGroup({ photos, onDismiss, onOpen }) {
+ return (
+
+ {/* Photo row */}
+
+ {photos.map((photo, i) => (
+
+
onOpen(photo)}
+ style={{
+ aspectRatio: '4/3',
+ cursor: 'pointer',
+ background: '#0c0c0c',
+ overflow: 'hidden',
+ position: 'relative',
+ }}
+ >
+ {photo.thumb_url ? (
+

+ ) : (
+
▪
+ )}
+
+ {/* Index badge */}
+
+ {i + 1}
+
+
+
+ {/* Meta */}
+
+ {fmtDate(photo.date_taken)}
+
+ {[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')}
+
+ {photo.camera_model && (
+
+ {photo.camera_model}
+
+ )}
+
+
+ ))}
+
+
+ {/* Footer */}
+
+
+ {photos.length} similar photos
+
+
+
+
+ );
+}
diff --git a/client/src/components/DuplicatesView.jsx b/client/src/components/DuplicatesView.jsx
new file mode 100644
index 0000000..7e31a71
--- /dev/null
+++ b/client/src/components/DuplicatesView.jsx
@@ -0,0 +1,110 @@
+import { useState, useEffect } from 'react';
+import { fetchDuplicates, scanDuplicates, dismissGroup } from '../api/photos.js';
+import DuplicateGroup from './DuplicateGroup.jsx';
+
+export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
+ const [groups, setGroups] = useState(null);
+ const [scanning, setScanning] = useState(false);
+ const [scanResult, setScanResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => { load(); }, []);
+
+ async function load() {
+ try {
+ const data = await fetchDuplicates();
+ setGroups(data.groups);
+ onGroupCountChange?.(data.total);
+ } catch (e) {
+ setError(e.message);
+ }
+ }
+
+ async function handleScan() {
+ setScanning(true);
+ setError(null);
+ try {
+ const result = await scanDuplicates();
+ setScanResult(result);
+ await load();
+ } catch (e) {
+ setError(e.message);
+ }
+ setScanning(false);
+ }
+
+ async function handleDismiss(photoIds) {
+ try {
+ await dismissGroup(photoIds);
+ setGroups(prev => {
+ const next = prev.filter(g => {
+ const s = new Set(g.map(p => p.id));
+ return !photoIds.every(id => s.has(id));
+ });
+ onGroupCountChange?.(next.length);
+ return next;
+ });
+ } catch {}
+ }
+
+ return (
+
+ {/* Toolbar */}
+
+
Similar photos
+
+
+
+ {scanResult && (
+
+ {scanResult.newPairs === 0
+ ? 'No new pairs'
+ : `${scanResult.newPairs} new pair${scanResult.newPairs !== 1 ? 's' : ''} found`}
+ {' · '}{scanResult.photos.toLocaleString()} photos · {scanResult.duration}ms
+
+ )}
+
+
+ {error && (
+
{error}
+ )}
+
+ {groups === null && (
+
Loading…
+ )}
+
+ {groups !== null && groups.length === 0 && (
+
+
No similar photos to review.
+
Click "Scan library" to compare perceptual hashes across your library.
+
+ )}
+
+ {groups !== null && groups.length > 0 && (
+
+
+ {groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss
+
+ {groups.map((group, i) => (
+
handleDismiss(group.map(p => p.id))}
+ onOpen={(photo) => onOpenPhoto(photo, group)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/FilterBar.jsx b/client/src/components/FilterBar.jsx
new file mode 100644
index 0000000..658e3dd
--- /dev/null
+++ b/client/src/components/FilterBar.jsx
@@ -0,0 +1,84 @@
+const EMPTY = { q: '', from: '', to: '', tagId: '', collectionId: '' };
+
+function isActive(f) {
+ return f.q || f.from || f.to || f.tagId || f.collectionId;
+}
+
+export default function FilterBar({ filters, onChange, tags, collections }) {
+ const s = { // shared input style
+ background: '#111', border: '1px solid #222', borderRadius: 5,
+ color: '#ccc', fontSize: 12, padding: '4px 8px',
+ outline: 'none',
+ };
+
+ return (
+
+ onChange({ ...filters, q: e.target.value })}
+ style={{ ...s, width: 160 }}
+ />
+
+ onChange({ ...filters, from: e.target.value })}
+ style={{ ...s, colorScheme: 'dark' }}
+ title="From date"
+ />
+ –
+ onChange({ ...filters, to: e.target.value })}
+ style={{ ...s, colorScheme: 'dark' }}
+ title="To date"
+ />
+
+ {tags.length > 0 && (
+
+ )}
+
+ {collections.length > 0 && (
+
+ )}
+
+ {isActive(filters) && (
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Lightbox.jsx b/client/src/components/Lightbox.jsx
new file mode 100644
index 0000000..a0e62f4
--- /dev/null
+++ b/client/src/components/Lightbox.jsx
@@ -0,0 +1,145 @@
+import { useState, useEffect } from 'react';
+import DetailPanel from './DetailPanel.jsx';
+
+export default function Lightbox({ photos, index, onClose, onChange }) {
+ const [showInfo, setShowInfo] = useState(false);
+ const photo = photos[index];
+
+ useEffect(() => {
+ function onKey(e) {
+ // Don't hijack shortcuts while typing in the detail panel
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
+ if (e.key === 'Escape') onClose();
+ if (e.key === 'ArrowLeft' && index > 0) onChange(index - 1);
+ if (e.key === 'ArrowRight' && index < photos.length - 1) onChange(index + 1);
+ if (e.key === 'i') setShowInfo(s => !s);
+ }
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [index, photos.length, onClose, onChange]);
+
+ useEffect(() => {
+ document.body.style.overflow = 'hidden';
+ return () => { document.body.style.overflow = ''; };
+ }, []);
+
+ if (!photo) return null;
+
+ return (
+
+
+ {/* ── Image area ── */}
+
+ {/* Toolbar */}
+
e.stopPropagation()}
+ style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }}
+ >
+ setShowInfo(s => !s)}
+ active={showInfo}
+ title="Info [i]"
+ >
+
+
+ ✕
+
+
+ {/* Left nav */}
+ {index > 0 && (
+
{ e.stopPropagation(); onChange(index - 1); }}>‹
+ )}
+ {/* Right nav */}
+ {index < photos.length - 1 && (
+
{ e.stopPropagation(); onChange(index + 1); }}>›
+ )}
+
+

e.stopPropagation()}
+ style={{
+ maxWidth: '100%',
+ maxHeight: 'calc(100vh - 20px)',
+ objectFit: 'contain',
+ display: 'block',
+ }}
+ />
+
+
+ {/* ── Detail panel ── */}
+ {showInfo && (
+
+
+
+ )}
+
+ );
+}
+
+function IconBtn({ onClick, active, title, children }) {
+ return (
+
+ );
+}
+
+function NavBtn({ side, onClick, children }) {
+ return (
+
+ );
+}
+
+function InfoIcon() {
+ return (
+
+ );
+}
diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx
new file mode 100644
index 0000000..54db137
--- /dev/null
+++ b/client/src/components/MapView.jsx
@@ -0,0 +1,104 @@
+import { useEffect, useState, useMemo } from 'react';
+import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
+import MarkerClusterGroup from 'react-leaflet-cluster';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import { fetchMapPhotos } from '../api/photos.js';
+
+// Custom dot icon — avoids Leaflet's broken default asset URLs in Vite
+const dotIcon = (color = '#4d9eff') =>
+ L.divIcon({
+ className: '',
+ html: ``,
+ iconSize: [12, 12],
+ iconAnchor: [6, 6],
+ popupAnchor: [0, -8],
+ });
+
+const ICON = dotIcon();
+
+function centroid(points) {
+ const lat = points.reduce((s, p) => s + p.lat, 0) / points.length;
+ const lon = points.reduce((s, p) => s + p.lon, 0) / points.length;
+ return [lat, lon];
+}
+
+export default function MapView({ onOpenPhoto }) {
+ const [points, setPoints] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchMapPhotos()
+ .then(setPoints)
+ .catch(e => setError(e.message));
+ }, []);
+
+ const center = useMemo(() => {
+ if (!points?.length) return [20, 0];
+ return centroid(points);
+ }, [points]);
+
+ if (error) return (
+ {error}
+ );
+
+ if (points === null) return (
+ Loading map…
+ );
+
+ if (points.length === 0) return (
+
+
No geotagged photos.
+
Photos with GPS metadata will appear here once indexed.
+
+ );
+
+ return (
+
+
+
+
+
+ {points.map(p => (
+
+
+
+ {p.thumb_url && (
+

onOpenPhoto(p)}
+ style={{
+ width: '100%', maxWidth: 200, display: 'block',
+ cursor: 'pointer', borderRadius: 3,
+ }}
+ />
+ )}
+ {p.date_taken && (
+
+ {new Date(p.date_taken).toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric',
+ })}
+
+ )}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/components/MonthGroup.jsx b/client/src/components/MonthGroup.jsx
new file mode 100644
index 0000000..70af5c8
--- /dev/null
+++ b/client/src/components/MonthGroup.jsx
@@ -0,0 +1,36 @@
+import PhotoCard from './PhotoCard.jsx';
+
+export default function MonthGroup({ label, photos, startIdx, onSelect }) {
+ return (
+
+
+ {label}
+
+ {photos.length.toLocaleString()}
+
+
+
+
+ {photos.map((photo, i) => (
+
onSelect(startIdx + i)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/PhotoCard.jsx b/client/src/components/PhotoCard.jsx
new file mode 100644
index 0000000..e19d36c
--- /dev/null
+++ b/client/src/components/PhotoCard.jsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+
+export default function PhotoCard({ photo, onClick }) {
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState(false);
+
+ return (
+ e.key === 'Enter' && onClick()}
+ style={{
+ aspectRatio: '1',
+ background: '#181818',
+ cursor: 'pointer',
+ overflow: 'hidden',
+ position: 'relative',
+ }}
+ >
+ {photo.thumb_url && !error ? (
+

setLoaded(true)}
+ onError={() => setError(true)}
+ style={{
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ display: 'block',
+ opacity: loaded ? 1 : 0,
+ transition: 'opacity 0.15s ease',
+ }}
+ />
+ ) : (
+
+ ▪
+
+ )}
+
+ );
+}
diff --git a/client/src/components/TagEditor.jsx b/client/src/components/TagEditor.jsx
new file mode 100644
index 0000000..2cdbc87
--- /dev/null
+++ b/client/src/components/TagEditor.jsx
@@ -0,0 +1,113 @@
+import { useState, useRef } from 'react';
+import { addTag, removeTag } from '../api/photos.js';
+
+export default function TagEditor({ photoId, initialTags }) {
+ const [tags, setTags] = useState(initialTags);
+ const [input, setInput] = useState('');
+ const [busy, setBusy] = useState(false);
+ const inputRef = useRef(null);
+
+ async function handleAdd(e) {
+ e.preventDefault();
+ const name = input.trim();
+ if (!name || busy) return;
+ if (tags.some(t => t.name === name)) { setInput(''); return; }
+
+ setBusy(true);
+ try {
+ const tag = await addTag(photoId, name);
+ setTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)));
+ setInput('');
+ } catch {}
+ setBusy(false);
+ inputRef.current?.focus();
+ }
+
+ async function handleRemove(tagId) {
+ setTags(prev => prev.filter(t => t.id !== tagId)); // optimistic
+ try {
+ await removeTag(photoId, tagId);
+ } catch {
+ // On failure re-fetch would be ideal; for now the optimistic update stays
+ }
+ }
+
+ return (
+
+ {tags.length > 0 && (
+
+ {tags.map(t => (
+ handleRemove(t.id)} />
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+// ── shared with CollectionEditor ─────────────────────────────────────────────
+
+export function Chip({ label, onRemove, color = 'neutral' }) {
+ const colors = {
+ neutral: { bg: '#222', border: '#333', text: '#ccc' },
+ green: { bg: '#1a2a1a', border: '#2a3a2a', text: '#8c8' },
+ };
+ const c = colors[color] ?? colors.neutral;
+ return (
+
+ {label}
+
+
+ );
+}
+
+export const iStyle = {
+ flex: 1,
+ background: '#181818', border: '1px solid #2a2a2a', borderRadius: 4,
+ padding: '5px 8px', color: '#ccc', fontSize: 12, outline: 'none',
+ minWidth: 0,
+};
+
+export function Btn({ children, style: overrides, disabled, ...props }) {
+ return (
+
+ );
+}
diff --git a/client/src/components/Timeline.jsx b/client/src/components/Timeline.jsx
new file mode 100644
index 0000000..658b277
--- /dev/null
+++ b/client/src/components/Timeline.jsx
@@ -0,0 +1,48 @@
+import MonthGroup from './MonthGroup.jsx';
+
+function groupByMonth(photos) {
+ const groups = [];
+ let current = null;
+
+ for (const photo of photos) {
+ const key = photo.date_taken ? photo.date_taken.slice(0, 7) : 'undated';
+
+ if (!current || current.key !== key) {
+ current = { key, label: monthLabel(photo.date_taken), photos: [] };
+ groups.push(current);
+ }
+ current.photos.push(photo);
+ }
+
+ return groups;
+}
+
+function monthLabel(dateTaken) {
+ if (!dateTaken) return 'Undated';
+ return new Date(dateTaken).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
+}
+
+export default function Timeline({ photos, onSelect }) {
+ if (!photos.length) return null;
+
+ const groups = groupByMonth(photos);
+ let globalIdx = 0;
+
+ return (
+
+ {groups.map(group => {
+ const startIdx = globalIdx;
+ globalIdx += group.photos.length;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..8a7ea6c
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,25 @@
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: #0f0f0f;
+ color: #e0e0e0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ min-height: 100dvh;
+ -webkit-font-smoothing: antialiased;
+}
+
+#root {
+ min-height: 100dvh;
+}
+
+code {
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ background: #1e1e1e;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.9em;
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
new file mode 100644
index 0000000..d9cc93c
--- /dev/null
+++ b/client/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App.jsx';
+
+createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..5c668b0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ server:
+ build: .
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ environment:
+ PORT: "3000"
+ DB_PATH: /data/library.db
+ POSTER_DIR: /data/posters
+ NODE_ENV: production
+ volumes:
+ - luma_data:/data
+ - /your/photo/directory:/photos:ro # ← change this path
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+
+ worker:
+ build: .
+ restart: unless-stopped
+ command: ["node", "worker/index.js"]
+ environment:
+ DB_PATH: /data/library.db
+ POSTER_DIR: /data/posters
+ PHOTO_DIR: /photos
+ volumes:
+ - luma_data:/data
+ - /your/photo/directory:/photos:ro # ← same path as server
+ depends_on:
+ server:
+ condition: service_healthy
+
+volumes:
+ luma_data:
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..73cba79
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2618 @@
+{
+ "name": "shpm",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "shpm",
+ "version": "0.1.0",
+ "dependencies": {
+ "better-sqlite3": "^13.0.3",
+ "chokidar": "^3.6.0",
+ "dotenv": "^16.4.5",
+ "exifr": "^7.1.3",
+ "express": "^4.19.2",
+ "leaflet": "^1.9.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-leaflet": "^4.2.1",
+ "react-leaflet-cluster": "^2.1.0",
+ "sharp": "^0.35.3"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.0.5",
+ "vite": "^8.2.1"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.144.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
+ "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@react-leaflet/core": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
+ "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
+ "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
+ "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
+ "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
+ "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
+ "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
+ "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
+ "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
+ "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
+ "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
+ "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
+ "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
+ "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
+ "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "license": "MIT"
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/leaflet.markercluster": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+ "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "leaflet": "^1.3.1"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "8.9.2",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
+ "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-leaflet": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
+ "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^2.1.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/react-leaflet-cluster": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz",
+ "integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==",
+ "license": "SEE LICENSE IN ",
+ "dependencies": {
+ "leaflet.markercluster": "^1.5.3"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.8.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-leaflet": "^4.0.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
+ "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.144.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.4",
+ "@rolldown/binding-darwin-arm64": "1.2.4",
+ "@rolldown/binding-darwin-x64": "1.2.4",
+ "@rolldown/binding-freebsd-x64": "1.2.4",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.4",
+ "@rolldown/binding-linux-arm64-musl": "1.2.4",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.4",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-gnu": "1.2.4",
+ "@rolldown/binding-linux-x64-musl": "1.2.4",
+ "@rolldown/binding-openharmony-arm64": "1.2.4",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.4",
+ "@rolldown/binding-win32-x64-msvc": "1.2.4"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.25",
+ "rolldown": "~1.2.1",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ca80d05
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "luma",
+ "version": "0.1.0",
+ "description": "Luma — self-hosted photo manager",
+ "type": "module",
+ "scripts": {
+ "worker": "node worker/index.js",
+ "server": "node server/index.js",
+ "dev:worker": "node --watch worker/index.js",
+ "dev:server": "node --watch server/index.js",
+ "dev:client": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "better-sqlite3": "^13.0.3",
+ "chokidar": "^3.6.0",
+ "dotenv": "^16.4.5",
+ "exifr": "^7.1.3",
+ "express": "^4.19.2",
+ "leaflet": "^1.9.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-leaflet": "^4.2.1",
+ "react-leaflet-cluster": "^2.1.0",
+ "sharp": "^0.35.3"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^6.0.5",
+ "vite": "^8.2.1"
+ }
+}
diff --git a/server/dedupe.js b/server/dedupe.js
new file mode 100644
index 0000000..403d61c
--- /dev/null
+++ b/server/dedupe.js
@@ -0,0 +1,80 @@
+import { hammingDistance } from '../worker/hash.js';
+
+const THRESHOLD = parseInt(process.env.DEDUPE_THRESHOLD ?? '10', 10);
+
+// Run pairwise pHash comparison and store new similar pairs.
+export function scan(db) {
+ const start = Date.now();
+
+ const photos = db.raw.prepare(
+ "SELECT id, phash FROM photos WHERE phash IS NOT NULL AND phash != '0000000000000000'"
+ ).all();
+
+ const insert = db.raw.prepare(
+ 'INSERT OR IGNORE INTO duplicate_pairs (photo_id_a, photo_id_b, distance) VALUES (?, ?, ?)'
+ );
+
+ let newPairs = 0;
+ db.raw.transaction(() => {
+ for (let i = 0; i < photos.length; i++) {
+ for (let j = i + 1; j < photos.length; j++) {
+ const dist = hammingDistance(photos[i].phash, photos[j].phash);
+ if (dist <= THRESHOLD) {
+ const r = insert.run(photos[i].id, photos[j].id, dist);
+ if (r.changes) newPairs++;
+ }
+ }
+ }
+ })();
+
+ return { photos: photos.length, newPairs, duration: Date.now() - start };
+}
+
+// Build connected-component groups from non-dismissed pairs.
+// Returns an array of photo-row arrays, largest groups first.
+export function getGroups(db) {
+ const pairs = db.getDupePairs();
+ if (!pairs.length) return [];
+
+ // Union-Find with path compression
+ const parent = new Map();
+
+ function find(x) {
+ if (!parent.has(x)) parent.set(x, x);
+ if (parent.get(x) !== x) parent.set(x, find(parent.get(x)));
+ return parent.get(x);
+ }
+
+ function union(a, b) {
+ parent.set(find(a), find(b));
+ }
+
+ for (const { photo_id_a, photo_id_b } of pairs) {
+ union(photo_id_a, photo_id_b);
+ }
+
+ // Normalize roots (path compression may have left stale entries)
+ for (const id of [...parent.keys()]) find(id);
+
+ // Group IDs by root
+ const groups = new Map();
+ for (const id of parent.keys()) {
+ const root = find(id);
+ if (!groups.has(root)) groups.set(root, []);
+ groups.get(root).push(id);
+ }
+
+ // Fetch photo details for all IDs
+ const allIds = [...parent.keys()];
+ const rows = db.raw.prepare(
+ `SELECT id, thumb_path, preview_path, date_taken, filesize, width, height, camera_model
+ FROM photos WHERE id IN (${allIds.map(() => '?').join(',')})`
+ ).all(allIds);
+
+ const photoMap = new Map(rows.map(r => [r.id, r]));
+
+ return [...groups.values()]
+ .filter(ids => ids.length >= 2)
+ .map(ids => ids.map(id => photoMap.get(id)).filter(Boolean))
+ .sort((a, b) => b.length - a.length);
+}
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..fc36c45
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,248 @@
+import 'dotenv/config';
+import express from 'express';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { openDb } from '../worker/db.js';
+import { scan, getGroups } from './dedupe.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = parseInt(process.env.PORT ?? '3000', 10);
+const DB_PATH = process.env.DB_PATH ?? './data/library.db';
+const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
+
+const app = express();
+const db = openDb(DB_PATH);
+const posterDirAbs = path.resolve(POSTER_DIR);
+
+app.use(express.json());
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function posterUrl(storedPath) {
+ if (!storedPath) return null;
+ const rel = path.relative(posterDirAbs, path.resolve(storedPath));
+ if (rel.startsWith('..')) return null;
+ return '/posters/' + rel.replace(/\\/g, '/');
+}
+
+function mapPhoto(p) {
+ return {
+ id: p.id,
+ date_taken: p.date_taken,
+ date_imported: p.date_imported,
+ camera_model: p.camera_model,
+ gps_lat: p.gps_lat,
+ gps_lon: p.gps_lon,
+ width: p.width,
+ height: p.height,
+ filesize: p.filesize,
+ thumb_url: posterUrl(p.thumb_path),
+ preview_url: posterUrl(p.preview_path),
+ };
+}
+
+function badRequest(res, msg) { res.status(400).json({ error: msg }); }
+function notFound(res) { res.status(404).json({ error: 'not found' }); }
+
+// Build SQL parts for the photo list query based on active filters.
+// Returns { joinSql, whereSql, params } where params covers joins then where clauses.
+function buildFilter(q, from, to, tagId, collectionId) {
+ const joinParts = [];
+ const joinParams = [];
+ const condParts = [];
+ const condParams = [];
+
+ if (tagId) {
+ joinParts.push('JOIN photo_tags _pt ON _pt.photo_id = p.id AND _pt.tag_id = ?');
+ joinParams.push(parseInt(tagId, 10));
+ }
+ if (collectionId) {
+ joinParts.push('JOIN photo_collections _pc ON _pc.photo_id = p.id AND _pc.collection_id = ?');
+ joinParams.push(parseInt(collectionId, 10));
+ }
+ if (q) {
+ condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)');
+ condParams.push(`%${q}%`, `%${q}%`);
+ }
+ if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
+ if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
+
+ return {
+ joinSql: joinParts.join(' '),
+ whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '',
+ params: [...joinParams, ...condParams],
+ };
+}
+
+// ── Static ────────────────────────────────────────────────────────────────────
+
+app.use('/posters', express.static(posterDirAbs));
+
+// ── Photos ────────────────────────────────────────────────────────────────────
+
+app.get('/api/health', (_req, res) => {
+ res.json({ ok: true, photos: db.count() });
+});
+
+const SELECT_COLS = `
+ p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
+ p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize
+`;
+
+app.get('/api/photos', (req, res) => {
+ const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
+ const cursor = req.query.cursor ?? null;
+ const { q, from, to, tagId, collectionId } = req.query;
+
+ const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId);
+
+ // Cursor condition — handles null dates correctly (NULLs sort last in DESC).
+ // When cursor_date is a real date: include photos before it AND all null-dated ones.
+ // When cursor_date is empty (we're in the null section): only null-dated with lower id.
+ let cursorSql = '';
+ const cursorParams = [];
+ if (cursor) {
+ const pipeAt = cursor.lastIndexOf('|');
+ const cursorDate = cursor.slice(0, pipeAt);
+ const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
+ if (cursorDate === '') {
+ cursorSql = whereSql
+ ? 'AND (p.date_taken IS NULL AND p.id < ?)'
+ : 'WHERE (p.date_taken IS NULL AND p.id < ?)';
+ cursorParams.push(cursorId);
+ } else {
+ const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
+ cursorSql = whereSql ? `AND ${clause}` : `WHERE ${clause}`;
+ cursorParams.push(cursorDate, cursorDate, cursorId);
+ }
+ }
+
+ const allParams = [...params, ...cursorParams, limit + 1];
+
+ const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql}
+ ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
+ const rows = db.raw.prepare(sql).all(allParams);
+
+ const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`;
+ const total = db.raw.prepare(countSql).get(params).n;
+
+ const hasMore = rows.length > limit;
+ const page = rows.slice(0, limit);
+ const last = page[page.length - 1];
+ const nextCursor = hasMore && last ? `${last.date_taken ?? ''}|${last.id}` : null;
+
+ res.json({ photos: page.map(mapPhoto), nextCursor, total });
+});
+
+app.get('/api/photos/map', (_req, res) => {
+ const rows = db.raw.prepare(`
+ SELECT id, gps_lat, gps_lon, thumb_path, preview_path, date_taken
+ FROM photos WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
+ `).all();
+ res.json(rows.map(r => ({
+ id: r.id,
+ lat: r.gps_lat,
+ lon: r.gps_lon,
+ date_taken: r.date_taken,
+ thumb_url: posterUrl(r.thumb_path),
+ preview_url: posterUrl(r.preview_path),
+ })));
+});
+
+app.get('/api/photos/:id', (req, res) => {
+ const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(req.params.id);
+ if (!row) return notFound(res);
+ res.json({
+ ...mapPhoto(row),
+ filepath: row.filepath,
+ phash: row.phash,
+ tags: db.getPhotoTags(row.id),
+ collections: db.getPhotoCollections(row.id),
+ });
+});
+
+// ── Tags ──────────────────────────────────────────────────────────────────────
+
+app.get('/api/tags', (_req, res) => res.json(db.listAllTags()));
+
+app.post('/api/photos/:id/tags', (req, res) => {
+ const photoId = parseInt(req.params.id, 10);
+ const name = (req.body.name ?? '').trim().slice(0, 100);
+ if (!name) return badRequest(res, 'name required');
+ res.status(201).json(db.addTagToPhoto(photoId, name));
+});
+
+app.delete('/api/photos/:id/tags/:tagId', (req, res) => {
+ db.removeTagFromPhoto(parseInt(req.params.id, 10), parseInt(req.params.tagId, 10));
+ res.sendStatus(204);
+});
+
+// ── Collections ───────────────────────────────────────────────────────────────
+
+app.get('/api/collections', (_req, res) => res.json(db.listCollections()));
+
+app.post('/api/collections', (req, res) => {
+ const name = (req.body.name ?? '').trim().slice(0, 200);
+ if (!name) return badRequest(res, 'name required');
+ try {
+ res.status(201).json(db.createCollection(name, req.body.description));
+ } catch (e) {
+ if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
+ throw e;
+ }
+});
+
+app.post('/api/photos/:id/collections', (req, res) => {
+ const colId = parseInt(req.body.collectionId, 10);
+ if (!colId) return badRequest(res, 'collectionId required');
+ db.addPhotoToCollection(parseInt(req.params.id, 10), colId);
+ res.sendStatus(204);
+});
+
+app.delete('/api/photos/:id/collections/:colId', (req, res) => {
+ db.removePhotoFromCollection(parseInt(req.params.id, 10), parseInt(req.params.colId, 10));
+ res.sendStatus(204);
+});
+
+// ── Duplicates ────────────────────────────────────────────────────────────────
+
+app.post('/api/dedupe/scan', (_req, res) => {
+ try { res.json(scan(db)); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/duplicates', (_req, res) => {
+ const rawGroups = getGroups(db);
+ const groups = rawGroups.map(group =>
+ group.map(p => ({
+ id: p.id,
+ date_taken: p.date_taken,
+ filesize: p.filesize,
+ width: p.width,
+ height: p.height,
+ camera_model: p.camera_model,
+ thumb_url: posterUrl(p.thumb_path),
+ preview_url: posterUrl(p.preview_path),
+ }))
+ );
+ res.json({ groups, total: groups.length });
+});
+
+app.post('/api/duplicates/dismiss', (req, res) => {
+ const ids = req.body.photoIds;
+ if (!Array.isArray(ids) || ids.length < 2) return badRequest(res, 'photoIds must be array of ≥2');
+ db.dismissDuplicates(ids.map(Number));
+ res.sendStatus(204);
+});
+
+// ── SPA fallback ──────────────────────────────────────────────────────────────
+
+const distPath = path.resolve(__dirname, '../dist');
+app.use(express.static(distPath));
+app.get(/^(?!\/api|\/posters)/, (_req, res) => {
+ res.sendFile(path.join(distPath, 'index.html'));
+});
+
+app.listen(PORT, () => {
+ console.log(`[server] http://localhost:${PORT} (${db.count()} photos)`);
+});
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..beef7e5
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ root: 'client',
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:3000',
+ '/posters': 'http://localhost:3000',
+ },
+ },
+ build: {
+ outDir: '../dist',
+ emptyOutDir: true,
+ },
+});
diff --git a/worker/db.js b/worker/db.js
new file mode 100644
index 0000000..ce82b7a
--- /dev/null
+++ b/worker/db.js
@@ -0,0 +1,161 @@
+import Database from 'better-sqlite3';
+import path from 'path';
+import fs from 'fs';
+
+export function openDb(dbPath) {
+ fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ filepath TEXT NOT NULL UNIQUE,
+ thumb_path TEXT,
+ preview_path TEXT,
+ date_taken TEXT,
+ date_imported TEXT NOT NULL,
+ camera_model TEXT,
+ gps_lat REAL,
+ gps_lon REAL,
+ phash TEXT,
+ width INTEGER,
+ height INTEGER,
+ filesize INTEGER
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
+ CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos(phash);
+
+ CREATE TABLE IF NOT EXISTS collections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ poster_path TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS photo_collections (
+ photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
+ PRIMARY KEY (photo_id, collection_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE
+ );
+
+ CREATE TABLE IF NOT EXISTS photo_tags (
+ photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (photo_id, tag_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS duplicate_pairs (
+ photo_id_a INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ photo_id_b INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
+ distance INTEGER NOT NULL,
+ dismissed INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (photo_id_a, photo_id_b),
+ CHECK (photo_id_a < photo_id_b)
+ );
+ `);
+
+ const s = {
+ // Photos
+ insert: db.prepare(`
+ INSERT OR IGNORE INTO photos
+ (filepath, thumb_path, preview_path, date_taken, date_imported,
+ camera_model, gps_lat, gps_lon, phash, width, height, filesize)
+ VALUES
+ (@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
+ @camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize)
+ `),
+ exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
+ remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
+ count: db.prepare('SELECT COUNT(*) AS n FROM photos'),
+
+ // Tags
+ tagByName: db.prepare('SELECT id, name FROM tags WHERE name = ?'),
+ tagInsert: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
+ tagList: db.prepare(`
+ SELECT t.id, t.name, COUNT(pt.photo_id) AS count
+ FROM tags t LEFT JOIN photo_tags pt ON pt.tag_id = t.id
+ GROUP BY t.id ORDER BY t.name
+ `),
+ tagOfPhoto: db.prepare(
+ 'SELECT t.id, t.name FROM tags t JOIN photo_tags pt ON pt.tag_id = t.id WHERE pt.photo_id = ? ORDER BY t.name'
+ ),
+ tagAttach: db.prepare('INSERT OR IGNORE INTO photo_tags (photo_id, tag_id) VALUES (?, ?)'),
+ tagDetach: db.prepare('DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?'),
+
+ // Collections
+ colList: db.prepare(`
+ SELECT c.id, c.name, c.description, c.created_at,
+ COUNT(pc.photo_id) AS photo_count
+ FROM collections c
+ LEFT JOIN photo_collections pc ON pc.collection_id = c.id
+ GROUP BY c.id ORDER BY c.name
+ `),
+ colInsert: db.prepare('INSERT INTO collections (name, description) VALUES (?, ?)'),
+ colOfPhoto: db.prepare(
+ 'SELECT c.id, c.name FROM collections c JOIN photo_collections pc ON pc.collection_id = c.id WHERE pc.photo_id = ? ORDER BY c.name'
+ ),
+ colAdd: db.prepare('INSERT OR IGNORE INTO photo_collections (photo_id, collection_id) VALUES (?, ?)'),
+ colRemove: db.prepare('DELETE FROM photo_collections WHERE photo_id = ? AND collection_id = ?'),
+
+ // Duplicates
+ dupePairs: db.prepare(
+ 'SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0'
+ ),
+ dupePairCount: db.prepare(
+ 'SELECT COUNT(*) AS n FROM duplicate_pairs WHERE dismissed = 0'
+ ),
+ };
+
+ const addTagTx = db.transaction((photoId, tagName) => {
+ s.tagInsert.run(tagName);
+ const tag = s.tagByName.get(tagName);
+ s.tagAttach.run(photoId, tag.id);
+ return tag;
+ });
+
+ return {
+ raw: db,
+
+ // Photos
+ insertPhoto: (row) => s.insert.run(row),
+ hasPhoto: (fp) => !!s.exists.get(fp),
+ removePhoto: (fp) => s.remove.run(fp),
+ count: () => s.count.get().n,
+
+ // Tags
+ addTagToPhoto: (photoId, tagName) => addTagTx(photoId, tagName),
+ removeTagFromPhoto: (photoId, tagId) => s.tagDetach.run(photoId, tagId),
+ getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId),
+ listAllTags: () => s.tagList.all(),
+
+ // Collections
+ listCollections: () => s.colList.all(),
+ createCollection: (name, desc) => {
+ const info = s.colInsert.run(name, desc ?? null);
+ return { id: info.lastInsertRowid, name, description: desc ?? null };
+ },
+ addPhotoToCollection: (photoId, colId) => s.colAdd.run(photoId, colId),
+ removePhotoFromCollection:(photoId, colId) => s.colRemove.run(photoId, colId),
+ getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId),
+
+ // Duplicates
+ getDupePairs: () => s.dupePairs.all(),
+ dismissDuplicates: (ids) => {
+ const ph = ids.map(() => '?').join(',');
+ db.prepare(
+ `UPDATE duplicate_pairs SET dismissed = 1
+ WHERE photo_id_a IN (${ph}) AND photo_id_b IN (${ph})`
+ ).run(...ids, ...ids);
+ },
+ };
+}
diff --git a/worker/hash.js b/worker/hash.js
new file mode 100644
index 0000000..dc4ad6f
--- /dev/null
+++ b/worker/hash.js
@@ -0,0 +1,31 @@
+import sharp from 'sharp';
+
+// dHash (difference hash): 9×8 → 64-bit comparison → 16-char hex string.
+// Two images with Hamming distance ≤ 10 are near-duplicates.
+export async function computeDHash(filepath) {
+ const { data } = await sharp(filepath)
+ .resize(9, 8, { fit: 'fill' })
+ .greyscale()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ let hash = 0n;
+ for (let row = 0; row < 8; row++) {
+ for (let col = 0; col < 8; col++) {
+ const left = data[row * 9 + col];
+ const right = data[row * 9 + col + 1];
+ hash = (hash << 1n) | (left < right ? 1n : 0n);
+ }
+ }
+ return hash.toString(16).padStart(16, '0');
+}
+
+export function hammingDistance(a, b) {
+ let xor = BigInt('0x' + a) ^ BigInt('0x' + b);
+ let dist = 0;
+ while (xor > 0n) {
+ dist += Number(xor & 1n);
+ xor >>= 1n;
+ }
+ return dist;
+}
diff --git a/worker/index.js b/worker/index.js
new file mode 100644
index 0000000..8cfa68c
--- /dev/null
+++ b/worker/index.js
@@ -0,0 +1,60 @@
+import 'dotenv/config';
+import chokidar from 'chokidar';
+import path from 'path';
+import { openDb } from './db.js';
+import { ingestFile, isSupported } from './ingest.js';
+
+const PHOTO_DIR = process.env.PHOTO_DIR ?? './photos';
+const DB_PATH = process.env.DB_PATH ?? './data/library.db';
+const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters';
+
+const db = openDb(DB_PATH);
+
+console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`);
+console.log(`[worker] db at ${path.resolve(DB_PATH)} (${db.count()} photos indexed)`);
+
+// Serial queue: chokidar fires many 'add' events at once on startup;
+// process them one at a time to keep memory and I/O pressure low.
+const queue = [];
+let busy = false;
+
+async function drain() {
+ if (busy) return;
+ busy = true;
+ while (queue.length > 0) {
+ const fp = queue.shift();
+ try {
+ await ingestFile(fp, db, POSTER_DIR);
+ } catch (err) {
+ console.error(`[worker] ${path.basename(fp)}: ${err.message}`);
+ }
+ }
+ busy = false;
+}
+
+const watcher = chokidar.watch(PHOTO_DIR, {
+ persistent: true,
+ ignoreInitial: false,
+ awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 },
+ ignored: /(^|[/\\])\../, // skip dotfiles
+});
+
+watcher.on('add', (fp) => {
+ if (!isSupported(fp)) return;
+ queue.push(path.resolve(fp));
+ drain();
+});
+
+watcher.on('unlink', (fp) => {
+ const resolved = path.resolve(fp);
+ db.removePhoto(resolved);
+ console.log(`[worker] removed ${path.basename(fp)}`);
+});
+
+watcher.on('error', (err) => console.error('[worker] watcher error:', err));
+
+process.on('SIGINT', () => {
+ console.log('\n[worker] shutting down');
+ watcher.close();
+ process.exit(0);
+});
diff --git a/worker/ingest.js b/worker/ingest.js
new file mode 100644
index 0000000..803b12d
--- /dev/null
+++ b/worker/ingest.js
@@ -0,0 +1,94 @@
+import path from 'path';
+import fs from 'fs';
+import crypto from 'crypto';
+import sharp from 'sharp';
+import exifr from 'exifr';
+import { computeDHash } from './hash.js';
+
+const SUPPORTED = new Set([
+ '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
+ '.tiff', '.tif', '.gif', '.avif',
+]);
+
+export function isSupported(filepath) {
+ return SUPPORTED.has(path.extname(filepath).toLowerCase());
+}
+
+export async function ingestFile(filepath, db, posterDir) {
+ if (db.hasPhoto(filepath)) return;
+
+ const stat = fs.statSync(filepath);
+
+ let exif = {};
+ try {
+ exif = await exifr.parse(filepath, {
+ pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation'],
+ gps: true,
+ }) ?? {};
+ } catch {}
+
+ const dateTaken = exif.DateTimeOriginal ?? null;
+ const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null;
+ const gpsLat = exif.latitude ?? null;
+ const gpsLon = exif.longitude ?? null;
+
+ const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
+ fs.mkdirSync(dir, { recursive: true });
+
+ let width = null, height = null;
+
+ try {
+ const meta = await sharp(filepath, { failOn: 'none' }).metadata();
+ width = meta.width;
+ height = meta.height;
+
+ await sharp(filepath, { failOn: 'none' })
+ .rotate()
+ .resize(400, 400, { fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: 80 })
+ .toFile(thumb);
+
+ await sharp(filepath, { failOn: 'none' })
+ .rotate()
+ .resize(1600, 1600, { fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: 85 })
+ .toFile(preview);
+ } catch (err) {
+ console.error(`[ingest] poster failed ${path.basename(filepath)}: ${err.message}`);
+ return;
+ }
+
+ let phash = null;
+ try { phash = await computeDHash(filepath); } catch {}
+
+ db.insertPhoto({
+ filepath,
+ thumb_path: thumb,
+ preview_path: preview,
+ date_taken: dateTaken ? new Date(dateTaken).toISOString() : null,
+ date_imported: new Date().toISOString(),
+ camera_model: cameraModel,
+ gps_lat: gpsLat,
+ gps_lon: gpsLon,
+ phash,
+ width,
+ height,
+ filesize: stat.size,
+ });
+
+ console.log(`[ingest] ${path.basename(filepath)}`);
+}
+
+function posterPaths(filepath, posterDir, date) {
+ const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
+ const stem = path.basename(filepath, path.extname(filepath));
+ const name = `${id}_${stem}`;
+ const d = new Date(date);
+ const folder = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}`;
+ const dir = path.join(posterDir, folder);
+ return {
+ dir,
+ thumb: path.join(dir, `${name}_thumb.jpg`),
+ preview: path.join(dir, `${name}_preview.jpg`),
+ };
+}