1824 lines
54 KiB
TypeScript
1824 lines
54 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
|
type Photo = {
|
|
id: string;
|
|
name: string;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
capturedAt: string | null;
|
|
thumbUrl: string;
|
|
fullUrl: string;
|
|
source: "demo" | "nextcloud";
|
|
};
|
|
|
|
type ShareListingEntry = {
|
|
href: string;
|
|
name: string;
|
|
lastModified: string | null;
|
|
contentType: string;
|
|
isCollection: boolean;
|
|
};
|
|
|
|
function resolveDavUrlFromShareUrl(shareUrl: string): string {
|
|
const url = new URL(shareUrl);
|
|
const publicShare = url.pathname.match(/\/s\/([^/?#]+)/);
|
|
|
|
if (publicShare) {
|
|
return `${url.origin}/public.php/dav/files/${publicShare[1]}/`;
|
|
}
|
|
|
|
const davShare = url.pathname.match(/\/public\.php\/dav\/files\/([^/?#]+)\/?/);
|
|
|
|
if (davShare) {
|
|
return `${url.origin}/public.php/dav/files/${davShare[1]}/`;
|
|
}
|
|
|
|
throw new Error("Please enter a public Nextcloud share link.");
|
|
}
|
|
|
|
async function proxyUpstream(url: string, init?: RequestInit) {
|
|
const upstream = await fetch(url, init);
|
|
const body = await upstream.arrayBuffer();
|
|
|
|
return {
|
|
body: Buffer.from(body),
|
|
contentType: upstream.headers.get("content-type") ?? "application/octet-stream",
|
|
status: upstream.status
|
|
};
|
|
}
|
|
|
|
function htmlPage(): string {
|
|
return `<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>mapy-mg</title>
|
|
<link
|
|
rel="stylesheet"
|
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin=""
|
|
/>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f3efe6;
|
|
--panel: rgba(255, 255, 255, 0.82);
|
|
--border: rgba(15, 23, 42, 0.12);
|
|
--text: #16202a;
|
|
--muted: #5b6472;
|
|
--accent: #2d6cdf;
|
|
--accent-strong: #1f56c2;
|
|
--shadow: 0 18px 50px rgba(23, 31, 45, 0.14);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
height: 100%;
|
|
}
|
|
|
|
body {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(45, 108, 223, 0.16), transparent 30%),
|
|
radial-gradient(circle at bottom right, rgba(220, 167, 47, 0.18), transparent 28%),
|
|
var(--bg);
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: end;
|
|
gap: 16px;
|
|
padding: 20px 24px 12px;
|
|
}
|
|
|
|
h1,
|
|
h2,
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
.brand h1 {
|
|
font-size: clamp(1.6rem, 2.4vw, 2.2rem);
|
|
}
|
|
|
|
.brand p,
|
|
.meta,
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.layout {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: 360px 1fr;
|
|
gap: 16px;
|
|
padding: 0 16px 16px;
|
|
}
|
|
|
|
aside,
|
|
main,
|
|
.overlay-card {
|
|
background: var(--panel);
|
|
backdrop-filter: blur(14px);
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
aside {
|
|
min-height: 0;
|
|
padding: 18px;
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.card {
|
|
padding: 16px;
|
|
border-radius: 16px;
|
|
border: 1px solid var(--border);
|
|
background: rgba(255, 255, 255, 0.72);
|
|
}
|
|
|
|
.card h2 {
|
|
font-size: 1rem;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.card p,
|
|
.card small {
|
|
color: var(--muted);
|
|
}
|
|
|
|
label {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
input {
|
|
width: 100%;
|
|
border: 1px solid rgba(15, 23, 42, 0.18);
|
|
border-radius: 12px;
|
|
padding: 12px 14px;
|
|
font: inherit;
|
|
background: white;
|
|
}
|
|
|
|
.button-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
button {
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 10px 14px;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.button-icon {
|
|
width: 1em;
|
|
height: 1em;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.button-icon svg {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.primary {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.primary:hover {
|
|
background: var(--accent-strong);
|
|
}
|
|
|
|
.secondary {
|
|
background: rgba(15, 23, 42, 0.08);
|
|
color: var(--text);
|
|
}
|
|
|
|
.danger {
|
|
background: rgba(157, 23, 77, 0.12);
|
|
color: #9d174d;
|
|
}
|
|
|
|
.danger:hover {
|
|
background: rgba(157, 23, 77, 0.18);
|
|
}
|
|
|
|
.status {
|
|
margin-top: 10px;
|
|
font-size: 0.9rem;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid rgba(45, 108, 223, 0.22);
|
|
border-top-color: var(--accent);
|
|
opacity: 0;
|
|
transform: scale(0.8);
|
|
transition: opacity 120ms ease, transform 120ms ease;
|
|
}
|
|
|
|
.spinner.active {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.progress {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 10px;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
background: rgba(15, 23, 42, 0.1);
|
|
}
|
|
|
|
.progress-fill {
|
|
width: 0%;
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: linear-gradient(90deg, var(--accent), #67b2ff);
|
|
transition: width 180ms ease;
|
|
}
|
|
|
|
.progress-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
font-size: 0.82rem;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.list {
|
|
display: grid;
|
|
gap: 10px;
|
|
max-height: min(44vh, 380px);
|
|
overflow: auto;
|
|
padding-right: 2px;
|
|
}
|
|
|
|
.photo {
|
|
display: grid;
|
|
grid-template-columns: 52px 1fr;
|
|
gap: 12px;
|
|
align-items: center;
|
|
padding: 10px;
|
|
border-radius: 14px;
|
|
background: rgba(255, 255, 255, 0.72);
|
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
|
}
|
|
|
|
.photo img {
|
|
width: 52px;
|
|
height: 52px;
|
|
object-fit: cover;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.photo strong {
|
|
display: block;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.photo span {
|
|
color: var(--muted);
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.photo.active {
|
|
border-color: rgba(45, 108, 223, 0.42);
|
|
background: rgba(45, 108, 223, 0.12);
|
|
}
|
|
|
|
.empty-state {
|
|
padding: 14px;
|
|
border-radius: 14px;
|
|
border: 1px dashed rgba(15, 23, 42, 0.16);
|
|
color: var(--muted);
|
|
background: rgba(255, 255, 255, 0.45);
|
|
text-align: center;
|
|
}
|
|
|
|
main {
|
|
position: relative;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
display: grid;
|
|
grid-template-rows: minmax(460px, 1fr) auto;
|
|
gap: 16px;
|
|
}
|
|
|
|
.map-panel {
|
|
position: relative;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
background: var(--panel);
|
|
backdrop-filter: blur(14px);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
#map {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 460px;
|
|
}
|
|
|
|
.map-label {
|
|
position: absolute;
|
|
z-index: 500;
|
|
top: 16px;
|
|
left: 16px;
|
|
padding: 10px 12px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: none;
|
|
place-items: center;
|
|
background: rgba(8, 12, 18, 0.82);
|
|
z-index: 1000;
|
|
padding: 24px;
|
|
}
|
|
|
|
.overlay.open {
|
|
display: grid;
|
|
}
|
|
|
|
.overlay-card {
|
|
width: min(1120px, 100%);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.overlay-card header {
|
|
padding: 16px 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.overlay-card img {
|
|
display: block;
|
|
width: 100%;
|
|
max-height: 78vh;
|
|
object-fit: contain;
|
|
background: #111;
|
|
}
|
|
|
|
.close {
|
|
background: #111827;
|
|
color: white;
|
|
}
|
|
|
|
.thumb {
|
|
width: 180px;
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.thumb img {
|
|
width: 100%;
|
|
aspect-ratio: 4 / 3;
|
|
object-fit: cover;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.thumb button {
|
|
border: 0;
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.leaflet-popup-content-wrapper,
|
|
.leaflet-popup-tip {
|
|
background: rgba(255, 255, 255, 0.97);
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
margin: 10px 12px;
|
|
}
|
|
|
|
.timeline {
|
|
padding: 16px;
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.timeline-header,
|
|
.timeline-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.timeline-header h2 {
|
|
margin: 0 0 4px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.timeline-summary {
|
|
color: var(--muted);
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.timeline-chart {
|
|
position: relative;
|
|
min-height: 170px;
|
|
padding: 14px 12px 10px;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
background:
|
|
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.88)),
|
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 42%);
|
|
overflow: hidden;
|
|
touch-action: none;
|
|
user-select: none;
|
|
box-shadow:
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
|
inset 0 -1px 0 rgba(255, 255, 255, 0.03);
|
|
}
|
|
|
|
.timeline-chart::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background:
|
|
linear-gradient(to top, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
|
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
|
background-size: 100% 33.333%, 10% 100%;
|
|
opacity: 0.35;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.timeline-bars {
|
|
height: 124px;
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: minmax(10px, 1fr);
|
|
align-items: end;
|
|
gap: 2px;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.timeline-bar {
|
|
position: relative;
|
|
height: 100%;
|
|
display: grid;
|
|
align-items: end;
|
|
cursor: pointer;
|
|
border-radius: 6px 6px 3px 3px;
|
|
}
|
|
|
|
.timeline-bar-fill {
|
|
width: 100%;
|
|
border-radius: inherit;
|
|
min-height: 4px;
|
|
background: linear-gradient(180deg, rgba(96, 165, 250, 0.98), rgba(59, 130, 246, 0.36));
|
|
transition: transform 140ms ease, background 140ms ease, opacity 140ms ease, box-shadow 140ms ease;
|
|
transform-origin: bottom;
|
|
box-shadow: 0 0 0 1px rgba(30, 64, 175, 0.28);
|
|
}
|
|
|
|
.timeline-bar:hover .timeline-bar-fill,
|
|
.timeline-bar.active .timeline-bar-fill {
|
|
background: linear-gradient(180deg, #38bdf8, #0ea5e9);
|
|
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.38), 0 0 18px rgba(14, 165, 233, 0.18);
|
|
}
|
|
|
|
.timeline-bar.selected .timeline-bar-fill {
|
|
background: linear-gradient(180deg, #22c55e, #86efac);
|
|
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.35), 0 0 18px rgba(34, 197, 94, 0.18);
|
|
}
|
|
|
|
.timeline-bar.active .timeline-bar-fill {
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.timeline-bar.selected.active .timeline-bar-fill {
|
|
background: linear-gradient(180deg, #34d399, #10b981);
|
|
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.38), 0 0 22px rgba(16, 185, 129, 0.24);
|
|
}
|
|
|
|
.timeline-bar-meta {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: -18px;
|
|
transform: translateX(-50%);
|
|
white-space: nowrap;
|
|
font-size: 0.72rem;
|
|
color: rgba(226, 232, 240, 0.82);
|
|
font-variant-numeric: tabular-nums;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.timeline-axis {
|
|
position: relative;
|
|
height: 20px;
|
|
margin-top: -2px;
|
|
color: rgba(226, 232, 240, 0.72);
|
|
}
|
|
|
|
.timeline-axis-label {
|
|
position: absolute;
|
|
top: 0;
|
|
transform: translateX(-50%);
|
|
font-size: 0.72rem;
|
|
color: rgba(226, 232, 240, 0.78);
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.timeline-selection {
|
|
position: absolute;
|
|
top: 14px;
|
|
bottom: 34px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(56, 189, 248, 0.55);
|
|
background:
|
|
linear-gradient(180deg, rgba(56, 189, 248, 0.18), rgba(14, 165, 233, 0.08)),
|
|
rgba(14, 165, 233, 0.12);
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 120ms ease;
|
|
box-shadow:
|
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
|
0 0 22px rgba(14, 165, 233, 0.12);
|
|
}
|
|
|
|
.timeline-selection.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.timeline-brush {
|
|
position: absolute;
|
|
top: 14px;
|
|
bottom: 34px;
|
|
border-radius: 10px;
|
|
border: 1px dashed rgba(56, 189, 248, 0.9);
|
|
background: rgba(56, 189, 248, 0.16);
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
box-shadow: 0 0 20px rgba(56, 189, 248, 0.12);
|
|
}
|
|
|
|
.timeline-brush.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.timeline-footer {
|
|
font-size: 0.84rem;
|
|
color: var(--muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.timeline-footer span:last-child {
|
|
color: rgba(51, 65, 85, 0.92);
|
|
}
|
|
|
|
@media (max-width: 920px) {
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
main {
|
|
grid-template-rows: minmax(400px, 1fr) auto;
|
|
}
|
|
|
|
#map {
|
|
min-height: 460px;
|
|
}
|
|
|
|
.timeline-header,
|
|
.timeline-footer {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="brand">
|
|
<h1>mapy-mg</h1>
|
|
<p>Load photos, read EXIF locally in the browser, and show them on OpenStreetMap.</p>
|
|
</div>
|
|
<div class="meta">Client-side import · no image storage on the server</div>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<aside>
|
|
<section class="card">
|
|
<h2>Nextcloud Share</h2>
|
|
<p>Paste a public share link. The app loads the images in the browser and extracts GPS data locally.</p>
|
|
<label>
|
|
Share URL
|
|
<input
|
|
id="share-url"
|
|
placeholder="https://cloud.example.com/index.php/s/..."
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<div class="button-row">
|
|
<button class="primary" id="load-share" type="button">
|
|
<span class="button-icon" aria-hidden="true">
|
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M8 2v7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
<path d="M5 6.5 8 9.5 11 6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M3 12.5h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
</svg>
|
|
</span>
|
|
<span>Load share</span>
|
|
</button>
|
|
<button class="danger" id="cancel-share" type="button" disabled>
|
|
<span class="button-icon" aria-hidden="true">
|
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
</svg>
|
|
</span>
|
|
<span>Cancel</span>
|
|
</button>
|
|
</div>
|
|
<div class="status-row">
|
|
<span class="spinner" id="activity-spinner" aria-hidden="true"></span>
|
|
<div class="status" id="share-status">Ready to load a share.</div>
|
|
</div>
|
|
<div class="progress" aria-label="Loading progress">
|
|
<div class="progress-bar" aria-hidden="true">
|
|
<div class="progress-fill" id="progress-fill"></div>
|
|
</div>
|
|
<div class="progress-meta">
|
|
<span id="progress-text">0 / 0</span>
|
|
<span id="progress-detail">ready</span>
|
|
</div>
|
|
</div>
|
|
<small>Public Nextcloud shares are read via WebDAV.</small>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Photos</h2>
|
|
<small id="photo-count" class="muted">0 photos</small>
|
|
<div id="photo-list" class="list">
|
|
<div class="empty-state" id="empty-state">No images loaded yet.</div>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
|
|
<main>
|
|
<section class="map-panel">
|
|
<div class="map-label">Hover: thumbnail · click: fullscreen · route: time sorted</div>
|
|
<div id="map"></div>
|
|
</section>
|
|
<section class="card timeline" aria-label="Photo timeline">
|
|
<div class="timeline-header">
|
|
<div>
|
|
<h2>Timeline</h2>
|
|
<div class="timeline-summary" id="timeline-summary">No photos loaded yet.</div>
|
|
</div>
|
|
<button class="secondary" id="timeline-clear" type="button" disabled>Clear range</button>
|
|
</div>
|
|
<div class="timeline-chart" id="timeline-chart">
|
|
<div class="timeline-bars" id="timeline-bars"></div>
|
|
<div class="timeline-selection" id="timeline-selection"></div>
|
|
<div class="timeline-brush" id="timeline-brush"></div>
|
|
</div>
|
|
<div class="timeline-axis" id="timeline-axis"></div>
|
|
<div class="timeline-footer">
|
|
<span id="timeline-range">All photos</span>
|
|
<span id="timeline-unit">Scale: day</span>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<div class="overlay" id="overlay" aria-hidden="true">
|
|
<div class="overlay-card">
|
|
<header>
|
|
<strong id="overlay-title">Photo</strong>
|
|
<button class="close" id="close-overlay" type="button">
|
|
<span class="button-icon" aria-hidden="true">
|
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
|
</svg>
|
|
</span>
|
|
<span>Close</span>
|
|
</button>
|
|
</header>
|
|
<img id="overlay-image" alt="" />
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<script type="module">
|
|
import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm";
|
|
|
|
const state = {
|
|
photos: [],
|
|
visiblePhotos: [],
|
|
objectUrls: [],
|
|
processed: 0,
|
|
total: 0,
|
|
activePhotoId: null,
|
|
timelineSelection: null,
|
|
timelineViewport: null,
|
|
timeline: {
|
|
unit: "day",
|
|
bins: [],
|
|
start: null,
|
|
end: null
|
|
}
|
|
};
|
|
let activeImportController = null;
|
|
|
|
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19,
|
|
attribution: "© OpenStreetMap-Mitwirkende"
|
|
}).addTo(map);
|
|
|
|
const markers = L.layerGroup().addTo(map);
|
|
const route = L.polyline([], {
|
|
color: "#2d6cdf",
|
|
weight: 4,
|
|
opacity: 0.85
|
|
}).addTo(map);
|
|
|
|
const overlay = mustGet("overlay");
|
|
const overlayTitle = mustGet("overlay-title");
|
|
const overlayImage = mustGet("overlay-image");
|
|
const closeOverlay = mustGet("close-overlay");
|
|
const photoList = mustGet("photo-list");
|
|
const emptyState = mustGet("empty-state");
|
|
const photoCount = mustGet("photo-count");
|
|
const progressFill = mustGet("progress-fill");
|
|
const progressText = mustGet("progress-text");
|
|
const progressDetail = mustGet("progress-detail");
|
|
const activitySpinner = mustGet("activity-spinner");
|
|
const timelineSummary = mustGet("timeline-summary");
|
|
const timelineClear = mustGet("timeline-clear");
|
|
const timelineChart = mustGet("timeline-chart");
|
|
const timelineBars = mustGet("timeline-bars");
|
|
const timelineSelection = mustGet("timeline-selection");
|
|
const timelineBrush = mustGet("timeline-brush");
|
|
const timelineAxis = mustGet("timeline-axis");
|
|
const timelineRange = mustGet("timeline-range");
|
|
const timelineUnit = mustGet("timeline-unit");
|
|
const shareUrl = mustGet("share-url");
|
|
const shareStatus = mustGet("share-status");
|
|
const loadShare = mustGet("load-share");
|
|
const cancelShare = mustGet("cancel-share");
|
|
|
|
function mustGet(id) {
|
|
const element = document.getElementById(id);
|
|
if (!element) {
|
|
throw new Error("Missing UI element: " + id);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "no timestamp";
|
|
}
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? value
|
|
: date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
|
|
}
|
|
|
|
function normalizeExifDate(value) {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const match = value.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const [, year, month, day, hour, minute, second] = match;
|
|
const date = new Date(
|
|
Number(year),
|
|
Number(month) - 1,
|
|
Number(day),
|
|
Number(hour),
|
|
Number(minute),
|
|
Number(second)
|
|
);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function parseDateOrNull(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function resolveDavBaseUrl(shareUrlValue) {
|
|
const url = new URL(shareUrlValue.trim());
|
|
const publicShare = url.pathname.match(/\\/s\\/([^/?#]+)/);
|
|
if (publicShare) {
|
|
return url.origin + "/public.php/dav/files/" + publicShare[1] + "/";
|
|
}
|
|
|
|
const davShare = url.pathname.match(/\\/public\\.php\\/dav\\/files\\/([^/?#]+)\\/?/);
|
|
if (davShare) {
|
|
return url.origin + "/public.php/dav/files/" + davShare[1] + "/";
|
|
}
|
|
|
|
throw new Error("Please enter a public Nextcloud share link.");
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function getPhotoTimestamp(photo) {
|
|
if (!photo.capturedAt) {
|
|
return null;
|
|
}
|
|
|
|
const date = new Date(photo.capturedAt);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
}
|
|
|
|
function startOfHour(date) {
|
|
const copy = new Date(date);
|
|
copy.setMinutes(0, 0, 0);
|
|
return copy;
|
|
}
|
|
|
|
function startOfDay(date) {
|
|
const copy = new Date(date);
|
|
copy.setHours(0, 0, 0, 0);
|
|
return copy;
|
|
}
|
|
|
|
function startOfWeek(date) {
|
|
const copy = startOfDay(date);
|
|
const day = copy.getDay();
|
|
const offset = (day + 6) % 7;
|
|
copy.setDate(copy.getDate() - offset);
|
|
return copy;
|
|
}
|
|
|
|
function startOfMonth(date) {
|
|
const copy = startOfDay(date);
|
|
copy.setDate(1);
|
|
return copy;
|
|
}
|
|
|
|
function addUnit(date, unit) {
|
|
const copy = new Date(date);
|
|
|
|
if (unit === "hour") {
|
|
copy.setHours(copy.getHours() + 1);
|
|
return copy;
|
|
}
|
|
|
|
if (unit === "day") {
|
|
copy.setDate(copy.getDate() + 1);
|
|
return copy;
|
|
}
|
|
|
|
if (unit === "week") {
|
|
copy.setDate(copy.getDate() + 7);
|
|
return copy;
|
|
}
|
|
|
|
copy.setMonth(copy.getMonth() + 1);
|
|
return copy;
|
|
}
|
|
|
|
function getUnitStart(date, unit) {
|
|
if (unit === "hour") {
|
|
return startOfHour(date);
|
|
}
|
|
|
|
if (unit === "day") {
|
|
return startOfDay(date);
|
|
}
|
|
|
|
if (unit === "week") {
|
|
return startOfWeek(date);
|
|
}
|
|
|
|
return startOfMonth(date);
|
|
}
|
|
|
|
function chooseTimelineUnit(spanMs) {
|
|
const hour = 60 * 60 * 1000;
|
|
const day = 24 * hour;
|
|
const week = 7 * day;
|
|
|
|
if (spanMs <= 48 * hour) {
|
|
return "hour";
|
|
}
|
|
|
|
if (spanMs <= 21 * day) {
|
|
return "day";
|
|
}
|
|
|
|
if (spanMs <= 16 * week) {
|
|
return "week";
|
|
}
|
|
|
|
return "month";
|
|
}
|
|
|
|
function formatTimelineLabel(date, unit) {
|
|
if (unit === "hour") {
|
|
return date.toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
});
|
|
}
|
|
|
|
if (unit === "day") {
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric"
|
|
});
|
|
}
|
|
|
|
if (unit === "week") {
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric"
|
|
});
|
|
}
|
|
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
year: "numeric"
|
|
});
|
|
}
|
|
|
|
function formatTimelineSummary(photos, unit) {
|
|
if (!photos.length) {
|
|
return "No photos loaded yet.";
|
|
}
|
|
|
|
const count = photos.length + (photos.length === 1 ? " photo" : " photos");
|
|
const unitLabel = {
|
|
hour: "hourly",
|
|
day: "daily",
|
|
week: "weekly",
|
|
month: "monthly"
|
|
}[unit] || unit;
|
|
|
|
return count + " across a " + unitLabel + " timeline.";
|
|
}
|
|
|
|
function formatSelectionRange(start, end) {
|
|
return (
|
|
formatTimelineLabel(start, state.timeline.unit) +
|
|
" - " +
|
|
formatTimelineLabel(end, state.timeline.unit)
|
|
);
|
|
}
|
|
|
|
function buildTimeline(photos, viewport = null) {
|
|
const datedPhotos = photos
|
|
.map((photo) => ({ photo, date: getPhotoTimestamp(photo) }))
|
|
.filter((entry) => entry.date !== null)
|
|
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
|
|
if (!datedPhotos.length) {
|
|
return {
|
|
unit: "day",
|
|
bins: [],
|
|
start: null,
|
|
end: null
|
|
};
|
|
}
|
|
|
|
const dataStart = datedPhotos[0].date;
|
|
const dataEnd = datedPhotos[datedPhotos.length - 1].date;
|
|
const startDate = viewport ? new Date(viewport.start) : dataStart;
|
|
const endDate = viewport ? new Date(viewport.end) : dataEnd;
|
|
const spanMs = Math.max(1, endDate.getTime() - startDate.getTime());
|
|
const unit = chooseTimelineUnit(spanMs);
|
|
const domainStart = getUnitStart(startDate, unit);
|
|
const domainEnd = addUnit(getUnitStart(endDate, unit), unit);
|
|
const bins = [];
|
|
|
|
for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = addUnit(cursor, unit)) {
|
|
const next = addUnit(cursor, unit);
|
|
bins.push({
|
|
start: new Date(cursor),
|
|
end: next,
|
|
count: 0,
|
|
photoIds: [],
|
|
label: formatTimelineLabel(cursor, unit)
|
|
});
|
|
}
|
|
|
|
const binIndexByStart = new Map();
|
|
bins.forEach((bin, index) => {
|
|
binIndexByStart.set(bin.start.getTime(), index);
|
|
});
|
|
|
|
for (const entry of datedPhotos) {
|
|
if (viewport && (entry.date.getTime() < viewport.start || entry.date.getTime() >= viewport.end)) {
|
|
continue;
|
|
}
|
|
|
|
const bucketStart = getUnitStart(entry.date, unit).getTime();
|
|
const binIndex = binIndexByStart.get(bucketStart);
|
|
if (binIndex !== undefined) {
|
|
const bin = bins[binIndex];
|
|
bin.count += 1;
|
|
bin.photoIds.push(entry.photo.id);
|
|
}
|
|
}
|
|
|
|
return {
|
|
unit,
|
|
bins,
|
|
start: domainStart,
|
|
end: domainEnd
|
|
};
|
|
}
|
|
|
|
function updateStatus(message, tone = "info") {
|
|
shareStatus.textContent = message;
|
|
shareStatus.style.color = tone === "error" ? "#9d174d" : "var(--muted)";
|
|
}
|
|
|
|
function setProgress(processed, total, detail) {
|
|
state.processed = processed;
|
|
state.total = total;
|
|
progressFill.style.width = total > 0 ? Math.round((processed / total) * 100) + "%" : "0%";
|
|
progressText.textContent = processed + " / " + total;
|
|
progressDetail.textContent = detail;
|
|
}
|
|
|
|
function setImporting(isImporting) {
|
|
activitySpinner.classList.toggle("active", isImporting);
|
|
loadShare.disabled = isImporting;
|
|
cancelShare.disabled = !isImporting;
|
|
}
|
|
|
|
function clearObjectUrls() {
|
|
for (const url of state.objectUrls) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
state.objectUrls = [];
|
|
}
|
|
|
|
function clearGallery() {
|
|
state.photos = [];
|
|
state.visiblePhotos = [];
|
|
state.activePhotoId = null;
|
|
state.timelineSelection = null;
|
|
state.timelineViewport = null;
|
|
state.timeline = {
|
|
unit: "day",
|
|
bins: [],
|
|
start: null,
|
|
end: null
|
|
};
|
|
markers.clearLayers();
|
|
route.setLatLngs([]);
|
|
photoList.replaceChildren(emptyState);
|
|
emptyState.textContent = "No images loaded yet.";
|
|
photoCount.textContent = "0 photos";
|
|
timelineSummary.textContent = "No photos loaded yet.";
|
|
timelineRange.textContent = "All photos";
|
|
timelineUnit.textContent = "Scale: day";
|
|
timelineClear.disabled = true;
|
|
timelineBars.replaceChildren();
|
|
timelineAxis.replaceChildren();
|
|
timelineSelection.classList.remove("visible");
|
|
timelineSelection.style.left = "0";
|
|
timelineSelection.style.width = "0";
|
|
timelineBrush.classList.remove("visible");
|
|
setProgress(0, 0, "ready");
|
|
}
|
|
|
|
function openOverlay(photo) {
|
|
overlay.classList.add("open");
|
|
overlay.setAttribute("aria-hidden", "false");
|
|
overlayTitle.textContent = photo.name;
|
|
overlayImage.src = photo.fullUrl;
|
|
overlayImage.alt = photo.name;
|
|
}
|
|
|
|
function closeOverlayView() {
|
|
overlay.classList.remove("open");
|
|
overlay.setAttribute("aria-hidden", "true");
|
|
}
|
|
|
|
closeOverlay.addEventListener("click", closeOverlayView);
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) {
|
|
closeOverlayView();
|
|
}
|
|
});
|
|
|
|
function updateRouteView(photos) {
|
|
const routePoints = photos
|
|
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
|
|
.sort((a, b) => String(a.capturedAt).localeCompare(String(b.capturedAt)))
|
|
.map((photo) => [photo.latitude, photo.longitude]);
|
|
|
|
route.setLatLngs(routePoints);
|
|
|
|
if (routePoints.length === 1) {
|
|
map.setView(routePoints[0], 13);
|
|
} else if (routePoints.length > 1) {
|
|
map.fitBounds(route.getBounds(), { padding: [40, 40] });
|
|
}
|
|
}
|
|
|
|
function isPhotoInSelection(photo, selection = state.timelineSelection) {
|
|
if (!selection) {
|
|
return true;
|
|
}
|
|
|
|
const photoDate = getPhotoTimestamp(photo);
|
|
if (!photoDate) {
|
|
return false;
|
|
}
|
|
|
|
return photoDate.getTime() >= selection.start && photoDate.getTime() < selection.end;
|
|
}
|
|
|
|
function getVisiblePhotos() {
|
|
return state.photos.filter((photo) => isPhotoInSelection(photo));
|
|
}
|
|
|
|
function syncTimelineSelectionOverlay() {
|
|
const selection = state.timelineSelection;
|
|
if (!selection || !state.timeline.start || !state.timeline.end) {
|
|
timelineSelection.classList.remove("visible");
|
|
timelineSelection.style.left = "0";
|
|
timelineSelection.style.width = "0";
|
|
return;
|
|
}
|
|
|
|
const total = state.timeline.end.getTime() - state.timeline.start.getTime();
|
|
if (total <= 0) {
|
|
timelineSelection.classList.remove("visible");
|
|
return;
|
|
}
|
|
|
|
const left = clamp((selection.start - state.timeline.start.getTime()) / total, 0, 1);
|
|
const right = clamp((selection.end - state.timeline.start.getTime()) / total, 0, 1);
|
|
const startPct = Math.min(left, right) * 100;
|
|
const widthPct = Math.max(1, (Math.max(left, right) - Math.min(left, right)) * 100);
|
|
timelineSelection.classList.add("visible");
|
|
timelineSelection.style.left = startPct + "%";
|
|
timelineSelection.style.width = widthPct + "%";
|
|
}
|
|
|
|
function setTimelineSelection(start, end) {
|
|
if (start === null || end === null) {
|
|
state.timelineSelection = null;
|
|
state.timelineViewport = null;
|
|
timelineClear.disabled = true;
|
|
} else {
|
|
const normalizedStart = Math.min(start, end);
|
|
const normalizedEnd = Math.max(start, end);
|
|
state.timelineSelection = {
|
|
start: normalizedStart,
|
|
end: normalizedEnd
|
|
};
|
|
state.timelineViewport = {
|
|
start: normalizedStart,
|
|
end: normalizedEnd
|
|
};
|
|
timelineClear.disabled = false;
|
|
}
|
|
|
|
renderVisiblePhotos({ fitMap: true });
|
|
}
|
|
|
|
function clearTimelineSelection() {
|
|
setTimelineSelection(null, null);
|
|
}
|
|
|
|
function renderTimeline() {
|
|
const timelinePhotos = state.timelineSelection ? state.visiblePhotos : state.photos;
|
|
const timeline = buildTimeline(timelinePhotos, state.timelineViewport);
|
|
state.timeline = timeline;
|
|
timelineSummary.textContent = formatTimelineSummary(state.photos, timeline.unit);
|
|
timelineUnit.textContent = "Scale: " + timeline.unit + (state.timelineSelection ? " · zoomed" : "");
|
|
|
|
timelineBars.replaceChildren();
|
|
timelineAxis.replaceChildren();
|
|
|
|
if (!timeline.bins.length) {
|
|
timelineRange.textContent = "All photos";
|
|
syncTimelineSelectionOverlay();
|
|
return;
|
|
}
|
|
|
|
const maxCount = Math.max(...timeline.bins.map((bin) => bin.count), 1);
|
|
const labelStep = Math.max(1, Math.ceil(timeline.bins.length / 6));
|
|
|
|
timeline.bins.forEach((bin, index) => {
|
|
const bar = document.createElement("button");
|
|
const isSelected = state.timelineSelection
|
|
? bin.end.getTime() > state.timelineSelection.start && bin.start.getTime() < state.timelineSelection.end
|
|
: false;
|
|
const isActive = state.activePhotoId ? bin.photoIds.includes(state.activePhotoId) : false;
|
|
|
|
bar.type = "button";
|
|
bar.className = "timeline-bar" + (isSelected ? " selected" : "") + (isActive ? " active" : "");
|
|
bar.title = bin.label + " - " + bin.count + (bin.count === 1 ? " photo" : " photos");
|
|
bar.style.gridColumn = String(index + 1);
|
|
|
|
const fill = document.createElement("div");
|
|
fill.className = "timeline-bar-fill";
|
|
fill.style.height = Math.max(4, Math.round((bin.count / maxCount) * 100)) + "%";
|
|
bar.appendChild(fill);
|
|
|
|
const meta = document.createElement("span");
|
|
meta.className = "timeline-bar-meta";
|
|
meta.textContent = index % labelStep === 0 || index === timeline.bins.length - 1 ? bin.label : "";
|
|
bar.appendChild(meta);
|
|
|
|
bar.addEventListener("click", () => {
|
|
if (timelineClickSuppressed) {
|
|
return;
|
|
}
|
|
setTimelineSelection(bin.start.getTime(), bin.end.getTime());
|
|
});
|
|
|
|
timelineBars.appendChild(bar);
|
|
});
|
|
|
|
const selection = state.timelineSelection;
|
|
timelineRange.textContent = selection
|
|
? formatSelectionRange(new Date(selection.start), new Date(selection.end))
|
|
: "All photos";
|
|
|
|
const axisLabels = [];
|
|
timeline.bins.forEach((bin, index) => {
|
|
if (index % labelStep !== 0 && index !== timeline.bins.length - 1) {
|
|
return;
|
|
}
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "timeline-axis-label";
|
|
const position = timeline.bins.length === 1 ? 0 : index / (timeline.bins.length - 1);
|
|
label.style.left = position * 100 + "%";
|
|
label.textContent = bin.label;
|
|
axisLabels.push(label);
|
|
});
|
|
|
|
axisLabels.forEach((label) => timelineAxis.appendChild(label));
|
|
syncTimelineSelectionOverlay();
|
|
}
|
|
|
|
function renderVisiblePhotos(options = {}) {
|
|
const visiblePhotos = getVisiblePhotos();
|
|
state.visiblePhotos = visiblePhotos;
|
|
markers.clearLayers();
|
|
route.setLatLngs([]);
|
|
photoList.replaceChildren();
|
|
|
|
if (!visiblePhotos.length) {
|
|
emptyState.textContent = state.photos.length
|
|
? "No photos match the selected range."
|
|
: "No images loaded yet.";
|
|
photoList.appendChild(emptyState);
|
|
photoCount.textContent = "0 photos";
|
|
} else {
|
|
emptyState.textContent = "No images loaded yet.";
|
|
photoCount.textContent =
|
|
visiblePhotos.length + (visiblePhotos.length === 1 ? " photo shown" : " photos shown");
|
|
|
|
for (const photo of visiblePhotos) {
|
|
if (photo.latitude !== null && photo.longitude !== null) {
|
|
const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers);
|
|
|
|
marker.bindPopup(
|
|
'<div class="thumb">' +
|
|
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
|
|
'<strong>' + photo.name + '</strong>' +
|
|
'<small>' + formatDate(photo.capturedAt) + '</small>' +
|
|
'<button type="button" data-open="' + photo.id + '">Open</button>' +
|
|
'</div>'
|
|
);
|
|
|
|
marker.on("mouseover", () => marker.openPopup());
|
|
marker.on("click", () => {
|
|
state.activePhotoId = photo.id;
|
|
renderVisiblePhotos({ fitMap: false });
|
|
openOverlay(photo);
|
|
});
|
|
}
|
|
|
|
const item = document.createElement("article");
|
|
item.className = "photo" + (photo.id === state.activePhotoId ? " active" : "");
|
|
item.innerHTML =
|
|
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
|
|
'<div><strong>' + photo.name + '</strong><span>' + formatDate(photo.capturedAt) + '</span></div>';
|
|
item.addEventListener("click", () => {
|
|
state.activePhotoId = photo.id;
|
|
renderVisiblePhotos({ fitMap: false });
|
|
openOverlay(photo);
|
|
});
|
|
photoList.appendChild(item);
|
|
}
|
|
}
|
|
|
|
updateRouteView(visiblePhotos);
|
|
renderTimeline();
|
|
|
|
if (options.fitMap && visiblePhotos.length) {
|
|
const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
|
|
if (bounds.length === 1) {
|
|
map.setView([bounds[0].latitude, bounds[0].longitude], 13);
|
|
} else if (bounds.length > 1) {
|
|
map.fitBounds(bounds.map((photo) => [photo.latitude, photo.longitude]), { padding: [40, 40] });
|
|
}
|
|
}
|
|
}
|
|
|
|
let timelinePointerState = null;
|
|
let timelineClickSuppressed = false;
|
|
|
|
function updateTimelineBrushOverlay(startX, endX) {
|
|
if (!state.timeline.start || !state.timeline.end) {
|
|
timelineBrush.classList.remove("visible");
|
|
return;
|
|
}
|
|
|
|
const width = timelineChart.getBoundingClientRect().width;
|
|
if (width <= 0) {
|
|
timelineBrush.classList.remove("visible");
|
|
return;
|
|
}
|
|
|
|
const leftX = clamp(Math.min(startX, endX), 0, width);
|
|
const rightX = clamp(Math.max(startX, endX), 0, width);
|
|
timelineBrush.classList.add("visible");
|
|
timelineBrush.style.left = leftX + "px";
|
|
timelineBrush.style.width = Math.max(1, rightX - leftX) + "px";
|
|
}
|
|
|
|
function clearTimelineBrushOverlay() {
|
|
timelineBrush.classList.remove("visible");
|
|
timelineBrush.style.left = "0";
|
|
timelineBrush.style.width = "0";
|
|
}
|
|
|
|
function timeFromTimelineX(x) {
|
|
if (!state.timeline.start || !state.timeline.end) {
|
|
return null;
|
|
}
|
|
|
|
const width = timelineChart.getBoundingClientRect().width;
|
|
if (width <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const ratio = clamp(x / width, 0, 1);
|
|
const time = state.timeline.start.getTime() + ratio * (state.timeline.end.getTime() - state.timeline.start.getTime());
|
|
return new Date(time);
|
|
}
|
|
|
|
function selectTimelineBinAtX(x) {
|
|
if (!state.timeline.bins.length || !state.timeline.start || !state.timeline.end) {
|
|
return;
|
|
}
|
|
|
|
const width = timelineChart.getBoundingClientRect().width;
|
|
if (width <= 0) {
|
|
return;
|
|
}
|
|
|
|
const ratio = clamp(x / width, 0, 1);
|
|
const index = clamp(Math.floor(ratio * state.timeline.bins.length), 0, state.timeline.bins.length - 1);
|
|
const bin = state.timeline.bins[index];
|
|
setTimelineSelection(bin.start.getTime(), bin.end.getTime());
|
|
}
|
|
|
|
timelineChart.addEventListener("pointerdown", (event) => {
|
|
if (!state.timeline.bins.length) {
|
|
return;
|
|
}
|
|
|
|
const rect = timelineChart.getBoundingClientRect();
|
|
const startX = event.clientX - rect.left;
|
|
timelinePointerState = {
|
|
pointerId: event.pointerId,
|
|
startX,
|
|
currentX: startX,
|
|
moved: false
|
|
};
|
|
|
|
timelineChart.setPointerCapture(event.pointerId);
|
|
updateTimelineBrushOverlay(startX, startX);
|
|
});
|
|
|
|
timelineChart.addEventListener("pointermove", (event) => {
|
|
if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const rect = timelineChart.getBoundingClientRect();
|
|
const currentX = event.clientX - rect.left;
|
|
timelinePointerState.currentX = currentX;
|
|
timelinePointerState.moved = timelinePointerState.moved || Math.abs(currentX - timelinePointerState.startX) > 4;
|
|
updateTimelineBrushOverlay(timelinePointerState.startX, currentX);
|
|
});
|
|
|
|
function finishTimelinePointer(event) {
|
|
if (!timelinePointerState || timelinePointerState.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
timelineChart.releasePointerCapture(event.pointerId);
|
|
} catch {
|
|
// Pointer capture may already be released.
|
|
}
|
|
|
|
if (timelinePointerState.moved) {
|
|
const startTime = timeFromTimelineX(timelinePointerState.startX);
|
|
const endTime = timeFromTimelineX(timelinePointerState.currentX);
|
|
if (startTime && endTime) {
|
|
setTimelineSelection(startTime.getTime(), endTime.getTime());
|
|
}
|
|
timelineClickSuppressed = true;
|
|
setTimeout(() => {
|
|
timelineClickSuppressed = false;
|
|
}, 0);
|
|
} else {
|
|
selectTimelineBinAtX(timelinePointerState.currentX);
|
|
}
|
|
|
|
clearTimelineBrushOverlay();
|
|
timelinePointerState = null;
|
|
}
|
|
|
|
timelineChart.addEventListener("pointerup", finishTimelinePointer);
|
|
timelineChart.addEventListener("pointercancel", finishTimelinePointer);
|
|
|
|
timelineClear.addEventListener("click", clearTimelineSelection);
|
|
|
|
function appendPhoto(photo) {
|
|
state.photos.push(photo);
|
|
renderVisiblePhotos({ fitMap: state.photos.length === 1 });
|
|
}
|
|
|
|
function textContent(node, namespace, localName) {
|
|
const element = node.getElementsByTagNameNS(namespace, localName)[0];
|
|
return element ? element.textContent?.trim() ?? "" : "";
|
|
}
|
|
|
|
function parseListing(xmlText, baseUrl) {
|
|
const documentNode = new DOMParser().parseFromString(xmlText, "application/xml");
|
|
const responses = Array.from(documentNode.getElementsByTagNameNS("DAV:", "response"));
|
|
const entries = responses
|
|
.map((response) => {
|
|
const href = textContent(response, "DAV:", "href");
|
|
const prop = response.getElementsByTagNameNS("DAV:", "prop")[0];
|
|
if (!href || !prop) {
|
|
return null;
|
|
}
|
|
|
|
const contentType = textContent(prop, "DAV:", "getcontenttype");
|
|
const lastModified = textContent(prop, "DAV:", "getlastmodified") || null;
|
|
const displayName = textContent(prop, "DAV:", "displayname");
|
|
const isCollection = prop.getElementsByTagNameNS("DAV:", "collection").length > 0;
|
|
const absoluteUrl = new URL(href, baseUrl).toString();
|
|
|
|
return {
|
|
href: absoluteUrl,
|
|
name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "image"),
|
|
lastModified,
|
|
contentType,
|
|
isCollection
|
|
};
|
|
})
|
|
.filter((entry) => entry !== null);
|
|
|
|
return entries.filter((entry) => {
|
|
const resolved = entry.href.toLowerCase();
|
|
const imageLike = entry.contentType.startsWith("image/") || /\.(jpe?g|png|gif|webp|heic|heif|tiff?|avif)$/i.test(resolved);
|
|
return !entry.isCollection && imageLike;
|
|
});
|
|
}
|
|
|
|
async function loadShareListing(shareUrlValue, signal) {
|
|
const davBaseUrl = resolveDavBaseUrl(shareUrlValue);
|
|
const response = await fetch(
|
|
"/api/nextcloud/list?share=" + encodeURIComponent(shareUrlValue.trim()),
|
|
{
|
|
signal
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Could not load the Nextcloud listing: " + response.status);
|
|
}
|
|
|
|
return parseListing(await response.text(), davBaseUrl);
|
|
}
|
|
|
|
async function readRemotePhoto(entry, signal) {
|
|
const response = await fetch("/api/nextcloud/blob?url=" + encodeURIComponent(entry.href), {
|
|
signal
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Could not load image: " + entry.name);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const [gps, tags] = await Promise.all([exifr.gps(blob), exifr.parse(blob)]);
|
|
const capturedAt =
|
|
normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) ||
|
|
parseDateOrNull(entry.lastModified);
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
state.objectUrls.push(objectUrl);
|
|
|
|
return {
|
|
id: entry.href,
|
|
name: entry.name,
|
|
latitude: gps?.latitude ?? null,
|
|
longitude: gps?.longitude ?? null,
|
|
capturedAt,
|
|
thumbUrl: objectUrl,
|
|
fullUrl: objectUrl,
|
|
source: "nextcloud"
|
|
};
|
|
}
|
|
|
|
async function importFromNextcloud() {
|
|
if (activeImportController) {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
activeImportController = controller;
|
|
setImporting(true);
|
|
|
|
try {
|
|
clearObjectUrls();
|
|
clearGallery();
|
|
updateStatus("Loading share...");
|
|
const listing = await loadShareListing(shareUrl.value, controller.signal);
|
|
|
|
if (!listing.length) {
|
|
throw new Error("No images were found in the share.");
|
|
}
|
|
|
|
setProgress(0, listing.length, "checking files");
|
|
|
|
let loaded = 0;
|
|
let skipped = 0;
|
|
let processed = 0;
|
|
|
|
for (const entry of listing) {
|
|
if (controller.signal.aborted) {
|
|
throw controller.signal.reason ?? new Error("Import canceled");
|
|
}
|
|
|
|
try {
|
|
const photo = await readRemotePhoto(entry, controller.signal);
|
|
if (photo.latitude !== null && photo.longitude !== null) {
|
|
appendPhoto(photo);
|
|
loaded += 1;
|
|
} else {
|
|
skipped += 1;
|
|
}
|
|
} catch (error) {
|
|
skipped += 1;
|
|
console.error(error);
|
|
}
|
|
|
|
processed += 1;
|
|
setProgress(
|
|
processed,
|
|
listing.length,
|
|
"loaded " + loaded + ", skipped " + skipped
|
|
);
|
|
updateStatus(
|
|
"Import running: " +
|
|
loaded +
|
|
" images shown, " +
|
|
skipped +
|
|
" skipped."
|
|
);
|
|
}
|
|
|
|
if (!loaded) {
|
|
throw new Error("No images with GPS data were found.");
|
|
}
|
|
|
|
updateStatus(
|
|
"Nextcloud import complete: " +
|
|
loaded +
|
|
" images loaded" +
|
|
(skipped ? ", " + skipped + " skipped" : "") +
|
|
"."
|
|
);
|
|
setProgress(listing.length, listing.length, "done");
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
updateStatus("Import canceled.");
|
|
setProgress(state.processed, state.total, "canceled");
|
|
return;
|
|
}
|
|
|
|
console.error(error);
|
|
updateStatus(
|
|
"Import failed: " + (error instanceof Error ? error.message : "unknown error"),
|
|
"error"
|
|
);
|
|
} finally {
|
|
activeImportController = null;
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (target instanceof HTMLElement && target.dataset.open) {
|
|
const photo = state.photos.find((item) => item.id === target.dataset.open);
|
|
if (photo) {
|
|
openOverlay(photo);
|
|
}
|
|
}
|
|
});
|
|
|
|
loadShare.addEventListener("click", () => {
|
|
void importFromNextcloud();
|
|
});
|
|
|
|
cancelShare.addEventListener("click", () => {
|
|
if (activeImportController) {
|
|
activeImportController.abort("Import canceled by user");
|
|
}
|
|
});
|
|
|
|
clearGallery();
|
|
updateStatus("Ready to load a share.");
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function createRequestHandler() {
|
|
return async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
|
|
if (url.pathname === "/health") {
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify({ status: "ok" }));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/nextcloud/list") {
|
|
const share = url.searchParams.get("share");
|
|
|
|
if (!share) {
|
|
res.statusCode = 400;
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify({ error: "missing `share` parameter" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const davUrl = resolveDavUrlFromShareUrl(share);
|
|
const xml =
|
|
'<?xml version="1.0" encoding="UTF-8"?>' +
|
|
'<d:propfind xmlns:d="DAV:">' +
|
|
"<d:prop>" +
|
|
"<d:displayname/>" +
|
|
"<d:getlastmodified/>" +
|
|
"<d:getcontenttype/>" +
|
|
"<d:getcontentlength/>" +
|
|
"<d:resourcetype/>" +
|
|
"</d:prop>" +
|
|
"</d:propfind>";
|
|
|
|
const upstream = await proxyUpstream(davUrl, {
|
|
method: "PROPFIND",
|
|
headers: {
|
|
Depth: "1",
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
Accept: "application/xml, text/xml"
|
|
},
|
|
body: xml
|
|
});
|
|
|
|
res.statusCode = upstream.status;
|
|
res.setHeader("content-type", upstream.contentType);
|
|
res.end(upstream.body);
|
|
return;
|
|
} catch (error) {
|
|
res.statusCode = 502;
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(
|
|
JSON.stringify({
|
|
error: error instanceof Error ? error.message : "upstream request failed"
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/nextcloud/blob") {
|
|
const target = url.searchParams.get("url");
|
|
|
|
if (!target) {
|
|
res.statusCode = 400;
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify({ error: "missing `url` parameter" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const upstream = await proxyUpstream(target);
|
|
res.statusCode = upstream.status;
|
|
res.setHeader("content-type", upstream.contentType);
|
|
res.end(upstream.body);
|
|
return;
|
|
} catch (error) {
|
|
res.statusCode = 502;
|
|
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
res.end(
|
|
JSON.stringify({
|
|
error: error instanceof Error ? error.message : "upstream request failed"
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
res.end(htmlPage());
|
|
};
|
|
}
|