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 {
|
export function htmlPage(): string {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -154,9 +154,7 @@ ${APP_STYLES}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
<script type="module">
|
<script type="module" src="/client/app.js"></script>
|
||||||
${CLIENT_SCRIPT}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
handleClientAssetRequest,
|
||||||
|
isClientAssetPath
|
||||||
|
} from "./client-assets.js";
|
||||||
import { htmlPage } from "./page-template.js";
|
import { htmlPage } from "./page-template.js";
|
||||||
import {
|
import {
|
||||||
handleNextcloudBlobRequest,
|
handleNextcloudBlobRequest,
|
||||||
@@ -16,6 +20,11 @@ export function createRequestHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isClientAssetPath(url.pathname)) {
|
||||||
|
await handleClientAssetRequest(url, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/nextcloud/list") {
|
if (url.pathname === "/api/nextcloud/list") {
|
||||||
await handleNextcloudListRequest(url, res);
|
await handleNextcloudListRequest(url, res);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user