refactor: modularize browser client

This commit is contained in:
br0tkasten committed 2026-06-13 08:22:11 +02:00
1 parent 9c1a6acbb5
commit f061dd2f38
8 files changed
+1279 -1226

No files matched your search

+1023
View File
File diff suppressed because it is too large. Load diff
+17
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]
});
}
+53
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
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
};
}