refactor: modularize browser client

This commit is contained in:
2026-06-13 08:22:11 +02:00
parent 9c1a6acbb5
commit f061dd2f38
8 changed files with 1279 additions and 1226 deletions

1023
src/client/app.ts Normal file

File diff suppressed because it is too large Load Diff

17
src/client/map-view.ts Normal file
View File

@@ -0,0 +1,17 @@
type MarkerPhoto = {
id: string;
name: string;
thumbUrl: string;
};
export function createPhotoMarkerIcon(leaflet: any, photo: MarkerPhoto, activePhotoId: string | null, escapeHtml: (value: unknown) => string) {
const activeClass = photo.id === activePhotoId ? " active" : "";
return leaflet.divIcon({
className: "photo-map-marker" + activeClass,
html:
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />',
iconSize: [44, 44],
iconAnchor: [22, 22],
popupAnchor: [0, -22]
});
}

View File

@@ -0,0 +1,53 @@
function textContent(node: Element, namespace: string, localName: string): string {
const element = node.getElementsByTagNameNS(namespace, localName)[0];
return element ? element.textContent?.trim() ?? "" : "";
}
export function resolveDavBaseUrl(shareUrlValue: string): string {
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.");
}
export function parseListing(xmlText: string, baseUrl: string) {
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;
});
}

145
src/client/timeline.ts Normal file
View File

@@ -0,0 +1,145 @@
type TimelinePhoto = {
id: string;
capturedAt: string | null;
};
type TimelineViewport = {
start: number;
end: number;
} | null;
type TimelineBin = {
start: Date;
end: Date;
count: number;
photoIds: string[];
label: string;
};
export function getPhotoTimestamp(photo: TimelinePhoto): Date | null {
if (!photo.capturedAt) {
return null;
}
const date = new Date(photo.capturedAt);
return Number.isNaN(date.getTime()) ? null : date;
}
function getTimelineIntervalMs(spanMs: number): number {
const minute = 60 * 1000;
const hour = 60 * minute;
if (spanMs <= 12 * hour) {
return 5 * minute;
}
if (spanMs <= 2 * 24 * hour) {
return 15 * minute;
}
if (spanMs <= 7 * 24 * hour) {
return 30 * minute;
}
if (spanMs <= 21 * 24 * hour) {
return 60 * minute;
}
if (spanMs <= 60 * 24 * hour) {
return 2 * hour;
}
return 4 * hour;
}
function floorToInterval(date: Date, intervalMs: number): Date {
return new Date(Math.floor(date.getTime() / intervalMs) * intervalMs);
}
function ceilToInterval(date: Date, intervalMs: number): Date {
return new Date(Math.ceil(date.getTime() / intervalMs) * intervalMs);
}
function formatTimelineLabel(date: Date): string {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
export function formatTimelineSummary(photos: TimelinePhoto[]): string {
if (!photos.length) {
return "No photos imported yet.";
}
const count = photos.length + (photos.length === 1 ? " photo" : " photos");
return count + " across a compact timeline.";
}
export function buildTimeline(photos: TimelinePhoto[], viewport: TimelineViewport = null) {
const datedPhotos = photos
.map((photo) => ({ photo, date: getPhotoTimestamp(photo) }))
.filter((entry): entry is { photo: TimelinePhoto; date: Date } => entry.date !== null)
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (!datedPhotos.length) {
return {
intervalMs: 15 * 60 * 1000,
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 intervalMs = getTimelineIntervalMs(spanMs);
const domainStart = floorToInterval(startDate, intervalMs);
const domainEnd = ceilToInterval(endDate, intervalMs);
const bins: TimelineBin[] = [];
for (let cursor = new Date(domainStart); cursor < domainEnd; cursor = new Date(cursor.getTime() + intervalMs)) {
const next = new Date(cursor.getTime() + intervalMs);
bins.push({
start: new Date(cursor),
end: next,
count: 0,
photoIds: [],
label: formatTimelineLabel(cursor)
});
}
const binIndexByStart = new Map<number, number>();
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 = floorToInterval(entry.date, intervalMs).getTime();
const binIndex = binIndexByStart.get(bucketStart);
if (binIndex !== undefined) {
const bin = bins[binIndex];
if (!bin) {
continue;
}
bin.count += 1;
bin.photoIds.push(entry.photo.id);
}
}
return {
intervalMs,
bins,
start: domainStart,
end: domainEnd
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
import { readFile } from "node:fs/promises";
import type { ServerResponse } from "node:http";
const CLIENT_ASSET_PREFIX = "/client/";
export function isClientAssetPath(pathname: string): boolean {
return pathname.startsWith(CLIENT_ASSET_PREFIX);
}
export async function handleClientAssetRequest(url: URL, res: ServerResponse): Promise<void> {
const assetName = url.pathname.slice(CLIENT_ASSET_PREFIX.length);
if (!/^[a-z0-9-]+\.js$/i.test(assetName)) {
res.statusCode = 404;
res.end("Not found");
return;
}
try {
const assetUrl = new URL(`../client/${assetName}`, import.meta.url);
const source = await readFile(assetUrl, "utf8");
res.statusCode = 200;
res.setHeader("content-type", "text/javascript; charset=utf-8");
res.setHeader("cache-control", "no-cache");
res.end(source);
} catch {
res.statusCode = 404;
res.end("Not found");
}
}

View File

@@ -1,4 +1,4 @@
import { APP_STYLES, CLIENT_SCRIPT } from "./app-assets.js";
import { APP_STYLES } from "./app-assets.js";
export function htmlPage(): string {
return `<!doctype html>
@@ -154,9 +154,7 @@ ${APP_STYLES}
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script type="module">
${CLIENT_SCRIPT}
</script>
<script type="module" src="/client/app.js"></script>
</body>
</html>`;
}

View File

@@ -1,3 +1,7 @@
import {
handleClientAssetRequest,
isClientAssetPath
} from "./client-assets.js";
import { htmlPage } from "./page-template.js";
import {
handleNextcloudBlobRequest,
@@ -16,6 +20,11 @@ export function createRequestHandler() {
return;
}
if (isClientAssetPath(url.pathname)) {
await handleClientAssetRequest(url, res);
return;
}
if (url.pathname === "/api/nextcloud/list") {
await handleNextcloudListRequest(url, res);
return;