@hearthlog/module-gallery 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +11 -2
  3. package/hearthlog.module.json +41 -0
  4. package/package.json +42 -3
  5. package/public/app/App.svelte +448 -0
  6. package/public/app/ambient.d.ts +5 -0
  7. package/public/app/client.ts +7 -0
  8. package/public/app/types.ts +53 -0
  9. package/public/client-assets.ts +17 -0
  10. package/public/contract.ts +211 -0
  11. package/public/index.ts +237 -0
  12. package/public/tsconfig.json +21 -0
  13. package/src/components/gallery/AlbumCard.svelte +41 -0
  14. package/src/components/gallery/AlbumSection.svelte +323 -0
  15. package/src/components/gallery/GalleryMonthBlock.svelte +159 -0
  16. package/src/components/gallery/GalleryTimeline.svelte +294 -0
  17. package/src/components/gallery/PhotoThumb.svelte +26 -0
  18. package/src/components/gallery/PhotoTile.svelte +163 -0
  19. package/src/components/gallery/PhotoViewer.svelte +219 -0
  20. package/src/components/gallery/PublicationManager.svelte +386 -0
  21. package/src/components/gallery/PublicationPreviewPhoto.svelte +33 -0
  22. package/src/components/gallery/gallery-blocks.svelte.ts +30 -0
  23. package/src/components/gallery/view/GallerySpine.svelte +239 -0
  24. package/src/components/gallery/view/GalleryTile.svelte +157 -0
  25. package/src/components/gallery/view/JustifiedRows.svelte +54 -0
  26. package/src/components/gallery/view/MonthHeading.svelte +53 -0
  27. package/src/components/gallery/view/ViewerFrame.svelte +362 -0
  28. package/src/data/definitions.ts +93 -0
  29. package/src/data/gallery.ts +413 -0
  30. package/src/data/repositories.ts +8 -0
  31. package/src/gallery/backup-archive.ts +195 -0
  32. package/src/gallery/backup.ts +140 -0
  33. package/src/gallery/exif.ts +240 -0
  34. package/src/gallery/file-store.ts +206 -0
  35. package/src/gallery/format.ts +101 -0
  36. package/src/gallery/interaction.ts +6 -0
  37. package/src/gallery/layout.ts +67 -0
  38. package/src/gallery/public-details.ts +73 -0
  39. package/src/gallery/publication-api.ts +68 -0
  40. package/src/gallery/selection.ts +25 -0
  41. package/src/gallery/timeline.ts +77 -0
  42. package/src/pages/gallery.svelte +737 -0
  43. package/src/pages/gallery.ts +20 -0
  44. package/src/server/files.ts +229 -0
  45. package/src/server/index.ts +18 -0
  46. package/src/server/ledger.ts +42 -0
  47. package/src/server/publications.ts +576 -0
  48. package/src/startup.ts +2 -0
@@ -0,0 +1,101 @@
1
+ import { format } from 'date-fns';
2
+ import { formatNumber } from '@hearthlog/sdk/format';
3
+ import { formatFileSize } from '@hearthlog/sdk/format';
4
+
5
+ export interface PhotoDetailSource {
6
+ captured_at: string;
7
+ timeline_day: string;
8
+ camera_make: string;
9
+ camera_model: string;
10
+ lens_model: string;
11
+ focal_length: number;
12
+ f_number: number;
13
+ exposure_time: number;
14
+ iso: number;
15
+ latitude: number | null;
16
+ longitude: number | null;
17
+ width: number;
18
+ height: number;
19
+ byte_size: number;
20
+ }
21
+
22
+ /** Capture time in the camera's own wall-clock time; the import day without one. */
23
+ export function formatPhotoDate(photo: Pick<PhotoDetailSource, 'captured_at' | 'timeline_day'>): string {
24
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/.exec(photo.captured_at || photo.timeline_day);
25
+ if (!match) return '';
26
+ const [, year, month, day, hour, minute] = match;
27
+ const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour ?? 0), Number(minute ?? 0));
28
+ return hour === undefined ? format(date, 'd MMMM yyyy') : format(date, 'd MMMM yyyy, HH:mm');
29
+ }
30
+
31
+ export function formatCamera(photo: Pick<PhotoDetailSource, 'camera_make' | 'camera_model'>): string {
32
+ return [photo.camera_make, photo.camera_model].filter(Boolean).join(' ');
33
+ }
34
+
35
+ /** `ƒ/1.8 · 1/120 s · ISO 50 · 26 mm` from whichever values the camera recorded. */
36
+ export function formatExposure(photo: Pick<PhotoDetailSource, 'f_number' | 'exposure_time' | 'iso' | 'focal_length'>): string {
37
+ const parts: string[] = [];
38
+ if (photo.f_number > 0) parts.push(`ƒ/${formatNumber(photo.f_number, 1)}`);
39
+ if (photo.exposure_time > 0) {
40
+ parts.push(photo.exposure_time >= 1
41
+ ? `${formatNumber(photo.exposure_time, 1)} s`
42
+ : `1/${Math.round(1 / photo.exposure_time)} s`);
43
+ }
44
+ if (photo.iso > 0) parts.push(`ISO ${photo.iso}`);
45
+ if (photo.focal_length > 0) parts.push(`${formatNumber(photo.focal_length, 1)} mm`);
46
+ return parts.join(' · ');
47
+ }
48
+
49
+ export function formatPhotoFile(photo: Pick<PhotoDetailSource, 'width' | 'height' | 'byte_size'>): string {
50
+ const dimensions = photo.width > 0 && photo.height > 0 ? `${photo.width} × ${photo.height}` : '';
51
+ return [dimensions, photo.byte_size > 0 ? formatFileSize(photo.byte_size) : ''].filter(Boolean).join(' · ');
52
+ }
53
+
54
+ export function formatCoordinates(photo: Pick<PhotoDetailSource, 'latitude' | 'longitude'>): string {
55
+ if (photo.latitude === null || photo.longitude === null) return '';
56
+ const latitude = `${Math.abs(photo.latitude).toFixed(4)}° ${photo.latitude < 0 ? 'S' : 'N'}`;
57
+ const longitude = `${Math.abs(photo.longitude).toFixed(4)}° ${photo.longitude < 0 ? 'W' : 'E'}`;
58
+ return `${latitude}, ${longitude}`;
59
+ }
60
+
61
+ export function mapHref(photo: Pick<PhotoDetailSource, 'latitude' | 'longitude'>): string {
62
+ if (photo.latitude === null || photo.longitude === null) return '';
63
+ return `https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`;
64
+ }
65
+
66
+ export interface PhotoDetailRow {
67
+ label: string;
68
+ value: string;
69
+ /** A link that opens the value elsewhere, such as a map. */
70
+ href?: string;
71
+ }
72
+
73
+ /** The camera, exposure, file, and location lines the viewer lists, leaving out any it lacks. */
74
+ export function photoDetailRows(photo: PhotoDetailSource, options: { file?: boolean } = {}): PhotoDetailRow[] {
75
+ const rows: PhotoDetailRow[] = [];
76
+ const camera = [formatCamera(photo), photo.lens_model].filter(Boolean).join(' · ');
77
+ if (camera) rows.push({ label: 'Camera', value: camera });
78
+ const exposure = formatExposure(photo);
79
+ if (exposure) rows.push({ label: 'Exposure', value: exposure });
80
+ if (options.file ?? true) {
81
+ const file = formatPhotoFile(photo);
82
+ if (file) rows.push({ label: 'File', value: file });
83
+ }
84
+ const location = formatCoordinates(photo);
85
+ if (location) rows.push({ label: 'Location', value: location, href: mapHref(photo) });
86
+ return rows;
87
+ }
88
+
89
+ /** `June` and `2024` for a `2024-06` month. */
90
+ export function formatMonthParts(month: string): { name: string; year: string } {
91
+ return { name: format(new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1), 'MMMM'), year: month.slice(0, 4) };
92
+ }
93
+
94
+ export function formatPhotoCount(count: number): string {
95
+ return `${formatNumber(count)} ${count === 1 ? 'photo' : 'photos'}`;
96
+ }
97
+
98
+ /** `Sat 14 Jun` for a `yyyy-MM-dd` day. */
99
+ export function formatTileDay(day: string): string {
100
+ return format(new Date(`${day.slice(0, 10)}T12:00:00`), 'EEE d MMM');
101
+ }
@@ -0,0 +1,6 @@
1
+ export function gallerySwipeDirection(startX: number, startY: number, endX: number, endY: number): -1 | 0 | 1 {
2
+ const horizontal = endX - startX;
3
+ const vertical = endY - startY;
4
+ if (Math.abs(horizontal) < 60 || Math.abs(horizontal) < Math.abs(vertical) * 1.5) return 0;
5
+ return horizontal < 0 ? 1 : -1;
6
+ }
@@ -0,0 +1,67 @@
1
+ /** Tiles keep a photo's shape between these ratios; wider panoramas and taller shots are cropped to them. */
2
+ export const MIN_TILE_RATIO = 0.5;
3
+ export const MAX_TILE_RATIO = 3;
4
+
5
+ /** Width over height as a tile shows it; a photo without dimensions is square. */
6
+ export function tileRatio(photo: { width: number; height: number }): number {
7
+ return photo.width > 0 && photo.height > 0
8
+ ? Math.min(MAX_TILE_RATIO, Math.max(MIN_TILE_RATIO, photo.width / photo.height))
9
+ : 1;
10
+ }
11
+
12
+ export interface GalleryRow {
13
+ /** The first photo in the row. */
14
+ start: number;
15
+ /** One past the last photo in the row. */
16
+ end: number;
17
+ height: number;
18
+ }
19
+
20
+ /** The most photos one row holds. */
21
+ const ROW_LIMIT = 12;
22
+
23
+ /**
24
+ * Presentation geometry for photos SQL already ordered: rows that fill the
25
+ * width exactly. The breaks are chosen for the whole block at once, so every
26
+ * row stays close to the target height; a short last row keeps the target
27
+ * height instead of stretching.
28
+ */
29
+ export function justifiedRows(ratios: readonly number[], width: number, target: number, gap: number): GalleryRow[] {
30
+ const count = ratios.length;
31
+ if (count === 0 || width <= 0 || target <= 0) return [];
32
+ const cost = new Float64Array(count + 1).fill(Infinity);
33
+ const previous = new Int32Array(count + 1);
34
+ cost[0] = 0;
35
+ for (let end = 1; end <= count; end++) {
36
+ let sum = 0;
37
+ for (let start = end - 1; start >= 0 && end - start <= ROW_LIMIT; start--) {
38
+ sum += ratios[start];
39
+ const size = end - start;
40
+ const height = (width - gap * (size - 1)) / sum;
41
+ if (height < target * 0.55 && size > 1) break;
42
+ let penalty: number;
43
+ if (end === count && height >= target) penalty = 0;
44
+ else if (height > target * 1.9) continue;
45
+ else penalty = ((height - target) / target) ** 2 * (height > target ? 1.6 : 1);
46
+ if (cost[start] + penalty < cost[end]) {
47
+ cost[end] = cost[start] + penalty;
48
+ previous[end] = start;
49
+ }
50
+ }
51
+ // A photo no row can hold at a sensible height stands alone.
52
+ if (cost[end] === Infinity) {
53
+ cost[end] = cost[end - 1] + 10;
54
+ previous[end] = end - 1;
55
+ }
56
+ }
57
+ const rows: GalleryRow[] = [];
58
+ for (let end = count; end > 0;) {
59
+ const start = previous[end];
60
+ let sum = 0;
61
+ for (let index = start; index < end; index++) sum += ratios[index];
62
+ const height = (width - gap * (end - start - 1)) / sum;
63
+ rows.push({ start, end, height: Math.min(end === count ? target : target * 2, height) });
64
+ end = start;
65
+ }
66
+ return rows.reverse();
67
+ }
@@ -0,0 +1,73 @@
1
+ import type { PublicGalleryPhotoDetails } from '../../public/contract.js';
2
+ import { formatCamera, type PhotoDetailSource } from './format.js';
3
+
4
+ /** What a published gallery shows about each photo; every switch starts on. */
5
+ export interface PublicDetailSwitches {
6
+ camera: boolean;
7
+ date: boolean;
8
+ location: boolean;
9
+ }
10
+
11
+ export const SHARE_ALL_DETAILS: PublicDetailSwitches = { camera: true, date: true, location: true };
12
+
13
+ type PrivateDetails = Pick<PhotoDetailSource,
14
+ 'captured_at' | 'timeline_day' | 'camera_make' | 'camera_model' | 'lens_model'
15
+ | 'f_number' | 'exposure_time' | 'iso' | 'focal_length' | 'latitude' | 'longitude'>;
16
+
17
+ const CAPTURED = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/;
18
+
19
+ /**
20
+ * The details a photo publishes under the owner's switches. The date is the
21
+ * camera's capture time, or the day the photo sits on the private timeline.
22
+ */
23
+ export function publicPhotoDetails(photo: PrivateDetails, switches: PublicDetailSwitches): PublicGalleryPhotoDetails {
24
+ const details: PublicGalleryPhotoDetails = {};
25
+ if (switches.date) {
26
+ const captured = CAPTURED.exec(photo.captured_at || '');
27
+ const takenAt = captured ? (captured[2] ? `${captured[1]}T${captured[2]}` : captured[1]) : CAPTURED.exec(photo.timeline_day)?.[1];
28
+ if (takenAt) details.takenAt = takenAt;
29
+ }
30
+ if (switches.camera) {
31
+ const camera = formatCamera(photo).slice(0, 120);
32
+ if (camera) details.camera = camera;
33
+ if (photo.lens_model) details.lens = photo.lens_model.slice(0, 120);
34
+ if (photo.f_number > 0) details.fNumber = photo.f_number;
35
+ if (photo.exposure_time > 0) details.exposureTime = photo.exposure_time;
36
+ if (photo.iso > 0) details.iso = photo.iso;
37
+ if (photo.focal_length > 0) details.focalLength = photo.focal_length;
38
+ }
39
+ if (switches.location && photo.latitude !== null && photo.longitude !== null) {
40
+ details.latitude = photo.latitude;
41
+ details.longitude = photo.longitude;
42
+ }
43
+ return details;
44
+ }
45
+
46
+ /** The switches an earlier review used, read from what its photos published. */
47
+ export function detailSwitchesFrom(photos: readonly PublicGalleryPhotoDetails[]): PublicDetailSwitches {
48
+ return {
49
+ camera: photos.some((photo) => photo.camera || photo.lens || photo.fNumber || photo.exposureTime || photo.iso || photo.focalLength),
50
+ date: photos.some((photo) => photo.takenAt),
51
+ location: photos.some((photo) => photo.latitude !== undefined),
52
+ };
53
+ }
54
+
55
+ /** Published details in the shape the shared formatters read. */
56
+ export function publicDetailSource(photo: PublicGalleryPhotoDetails & { width: number; height: number }): PhotoDetailSource {
57
+ return {
58
+ captured_at: photo.takenAt?.includes('T') ? photo.takenAt : '',
59
+ timeline_day: photo.takenAt?.slice(0, 10) ?? '',
60
+ camera_make: '',
61
+ camera_model: photo.camera ?? '',
62
+ lens_model: photo.lens ?? '',
63
+ focal_length: photo.focalLength ?? 0,
64
+ f_number: photo.fNumber ?? 0,
65
+ exposure_time: photo.exposureTime ?? 0,
66
+ iso: photo.iso ?? 0,
67
+ latitude: photo.latitude ?? null,
68
+ longitude: photo.longitude ?? null,
69
+ width: photo.width,
70
+ height: photo.height,
71
+ byte_size: 0,
72
+ };
73
+ }
@@ -0,0 +1,68 @@
1
+ import { fetchAccessProtected } from '@hearthlog/sdk/runtime';
2
+ import type { PublicGalleryPhotoDetails } from '../../public/contract.js';
3
+
4
+ /** A photo as reviewed, with the details the owner kept switched on. */
5
+ export interface PublicationReviewPhoto extends PublicGalleryPhotoDetails {
6
+ photoId: string;
7
+ fileVersionId: string;
8
+ caption: string;
9
+ alt: string;
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export interface PublicationReview {
15
+ title: string;
16
+ description: string;
17
+ photos: PublicationReviewPhoto[];
18
+ }
19
+
20
+ export interface PublicationStatus {
21
+ publicationId: string;
22
+ activeRevision: string | null;
23
+ publicUrl: string;
24
+ cleanupRevisions?: string[];
25
+ }
26
+
27
+ function endpoint(logId: string, publicationId: string): string {
28
+ return `/api/gallery-publications/v1/logs/${encodeURIComponent(logId)}/publications/${encodeURIComponent(publicationId)}`;
29
+ }
30
+
31
+ async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
32
+ const response = await fetchAccessProtected(url, init);
33
+ const value = await response.json().catch(() => null) as { error?: string } | null;
34
+ if (!response.ok) throw new Error(value?.error ?? 'The public gallery request failed.');
35
+ if (!value || typeof value !== 'object') throw new Error('The public gallery response was unavailable.');
36
+ return value as T;
37
+ }
38
+
39
+ export function publicationStatus(logId: string, publicationId: string): Promise<PublicationStatus> {
40
+ return request(endpoint(logId, publicationId));
41
+ }
42
+
43
+ export function publicationRevision(logId: string, publicationId: string, revision: string): Promise<PublicationReview> {
44
+ return request(`${endpoint(logId, publicationId)}/revisions/${encodeURIComponent(revision)}`);
45
+ }
46
+
47
+ export async function publishGallery(
48
+ logId: string, publicationId: string, revision: string, review: PublicationReview,
49
+ onProgress?: (completed: number, total: number) => void,
50
+ ): Promise<PublicationStatus> {
51
+ const base = `${endpoint(logId, publicationId)}/revisions/${encodeURIComponent(revision)}`;
52
+ const staged = await request<{ chunks: number }>(base, {
53
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(review),
54
+ });
55
+ for (let chunk = 0; chunk < staged.chunks; chunk += 1) {
56
+ await request(`${base}/chunks/${chunk}`, { method: 'POST' });
57
+ onProgress?.(chunk + 1, staged.chunks);
58
+ }
59
+ return request(`${base}/activate`, { method: 'POST' });
60
+ }
61
+
62
+ export function unpublishGallery(logId: string, publicationId: string): Promise<PublicationStatus> {
63
+ return request(`${endpoint(logId, publicationId)}/unpublish`, { method: 'POST' });
64
+ }
65
+
66
+ export function cleanupPublicRevision(logId: string, publicationId: string, revision: string): Promise<{ remaining: boolean; deleted: number }> {
67
+ return request(`${endpoint(logId, publicationId)}/revisions/${encodeURIComponent(revision)}/cleanup`, { method: 'POST' });
68
+ }
@@ -0,0 +1,25 @@
1
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+
3
+ export function gallerySelectionToken(searchParams: URLSearchParams): string {
4
+ const token = searchParams.get('selection') ?? '';
5
+ return UUID.test(token) ? token : '';
6
+ }
7
+
8
+ function key(tenantId: string, logId: string, token: string): string {
9
+ return `stor:gallery-selection:${tenantId}:${logId}:${token}`;
10
+ }
11
+
12
+ export function readGallerySelection(storage: Storage, tenantId: string, logId: string, token: string): Set<string> {
13
+ if (!UUID.test(token) || !tenantId || !logId) return new Set();
14
+ try {
15
+ const value: unknown = JSON.parse(storage.getItem(key(tenantId, logId, token)) ?? '[]');
16
+ if (!Array.isArray(value) || value.length > 10_000 || value.some((id) => typeof id !== 'string' || !UUID.test(id))) return new Set();
17
+ return new Set(value);
18
+ } catch { return new Set(); }
19
+ }
20
+
21
+ export function writeGallerySelection(storage: Storage, tenantId: string, logId: string, token: string, photoIds: Set<string>): void {
22
+ if (!UUID.test(token) || !tenantId || !logId || photoIds.size > 10_000) return;
23
+ try { storage.setItem(key(tenantId, logId, token), JSON.stringify([...photoIds])); }
24
+ catch { /* Selection remains usable in this tab when storage is unavailable. */ }
25
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The gallery timeline lists blocks of photos in time order. A block is a
3
+ * month, and a big month splits into parts of `GALLERY_BLOCK_SIZE` photos so
4
+ * the endless list only keeps what is on screen. Keys sort in time order:
5
+ * `2024-06/000`, `2024-06/001`, `2024-07/000`.
6
+ */
7
+ export const GALLERY_BLOCK_SIZE = 240;
8
+
9
+ export function galleryBlockKey(month: string, part = 0): string {
10
+ return `${month}/${String(part).padStart(3, '0')}`;
11
+ }
12
+
13
+ export function galleryBlockMonth(key: string): string {
14
+ return key.slice(0, 7);
15
+ }
16
+
17
+ export function galleryBlockPart(key: string): number {
18
+ return Number(key.slice(8)) || 0;
19
+ }
20
+
21
+ /**
22
+ * Orders block keys the way the timeline lists them: newest month first, and
23
+ * within a month the newest part (part 0) first.
24
+ */
25
+ export function compareGalleryBlockKeys(left: string, right: string): number {
26
+ const leftMonth = left.slice(0, 7);
27
+ const rightMonth = right.slice(0, 7);
28
+ if (leftMonth !== rightMonth) return leftMonth < rightMonth ? 1 : -1;
29
+ return galleryBlockPart(left) - galleryBlockPart(right);
30
+ }
31
+
32
+ /** Photo size, from small to large; each device keeps its own. */
33
+ export type GalleryDensity = 0 | 1 | 2;
34
+ export const GALLERY_DENSITY_KEY = 'gallery:density';
35
+
36
+ export function parseGalleryDensity(value: unknown): GalleryDensity {
37
+ return value === 0 || value === 1 || value === 2 ? value : 1;
38
+ }
39
+
40
+ export interface GalleryGeometry {
41
+ /** The width rows fill. */
42
+ width: number;
43
+ rowHeight: number;
44
+ gap: number;
45
+ /** A month header's height. */
46
+ header: number;
47
+ /** Space after a month's last block. */
48
+ monthGap: number;
49
+ }
50
+
51
+ /** Rows grow with the list: phones get shorter rows and narrower gaps. */
52
+ export function galleryGeometry(width: number, density: GalleryDensity): GalleryGeometry {
53
+ const wide = width >= 600;
54
+ return {
55
+ width,
56
+ rowHeight: (wide ? [150, 220, 310] : [96, 132, 184])[density],
57
+ gap: wide ? 5 : 3,
58
+ header: wide ? 60 : 52,
59
+ monthGap: wide ? 24 : 16,
60
+ };
61
+ }
62
+
63
+ export interface GalleryBlockShape {
64
+ part: number;
65
+ parts: number;
66
+ photos: number;
67
+ ratio_sum: number;
68
+ }
69
+
70
+ /** A block's height before it renders, from its photo count and the sum of their shapes. */
71
+ export function estimateGalleryBlock(block: GalleryBlockShape | undefined, geometry: GalleryGeometry): number {
72
+ if (!block || geometry.width <= 0) return geometry.header + geometry.rowHeight;
73
+ const rows = (block.ratio_sum * geometry.rowHeight + block.photos * geometry.gap) / (geometry.width + geometry.gap);
74
+ return (block.part === 0 ? geometry.header : 0)
75
+ + Math.max(1, Math.round(rows + 0.4)) * (geometry.rowHeight + geometry.gap)
76
+ + (block.part === block.parts - 1 ? geometry.monthGap : 0);
77
+ }