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 && ( +
+ + + Open in Maps ↗ + +
+ )} + +
+ +
+ +
+ +
+ +
+ ); +} + +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)} /> + ))} +
+ )} + +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd(e)} + placeholder="Add tag…" + disabled={busy} + maxLength={100} + style={iStyle} + /> + + +
+
+ ); +} + +// ── 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`), + }; +}