refactor: modularize browser client
This commit is contained in:
1023
src/client/app.ts
Normal file
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
17
src/client/map-view.ts
Normal 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
src/client/nextcloud-import.ts
Normal file
53
src/client/nextcloud-import.ts
Normal 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
145
src/client/timeline.ts
Normal 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
30
src/server/client-assets.ts
Normal file
30
src/server/client-assets.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user