@hearthlog/module-gallery 0.0.0-stage → 1.0.1

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 +72 -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,211 @@
1
+ export const PUBLIC_GALLERY_PREFIX = 'v1/galleries';
2
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+
4
+ /**
5
+ * Photo details an owner chose to share in the review. Each is absent when its
6
+ * switch is off; the public copies themselves never carry embedded metadata.
7
+ */
8
+ export interface PublicGalleryPhotoDetails {
9
+ /** When the photo was taken, by the camera's own clock: `yyyy-MM-dd` or `yyyy-MM-ddTHH:mm`. */
10
+ takenAt?: string;
11
+ camera?: string;
12
+ lens?: string;
13
+ fNumber?: number;
14
+ exposureTime?: number;
15
+ iso?: number;
16
+ focalLength?: number;
17
+ latitude?: number;
18
+ longitude?: number;
19
+ }
20
+
21
+ export interface PublicGalleryPhoto extends PublicGalleryPhotoDetails {
22
+ assetId: string;
23
+ caption: string;
24
+ alt: string;
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ /** A month of a dated gallery in time order, with the position of its first photo. */
30
+ export interface PublicGalleryMonth {
31
+ month: string;
32
+ photos: number;
33
+ position: number;
34
+ }
35
+
36
+ export interface PublicGalleryManifest {
37
+ version: 1;
38
+ title: string;
39
+ description: string;
40
+ photos: PublicGalleryPhoto[];
41
+ }
42
+
43
+ export interface PublicGalleryIndex {
44
+ version: 2;
45
+ title: string;
46
+ description: string;
47
+ photoCount: number;
48
+ /** Present when every photo shares its date and the photos run in time order. */
49
+ months?: PublicGalleryMonth[];
50
+ /** Set when the revision has `ids/` lookup shards, so a photo address can name its asset alone. */
51
+ ids?: true;
52
+ }
53
+
54
+ /** Positions of the photos whose asset ID starts with one shard prefix, in the reviewed order. */
55
+ export interface PublicGalleryIds {
56
+ version: 2;
57
+ positions: Record<string, number>;
58
+ }
59
+
60
+ export interface PublicGalleryPage {
61
+ version: 2;
62
+ photos: PublicGalleryPhoto[];
63
+ }
64
+
65
+ export function activeKey(publicationId: string): string {
66
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/active.json`;
67
+ }
68
+
69
+ /** Whole v1 manifest of revisions activated before sharding; new revisions no longer write one. */
70
+ export function manifestKey(publicationId: string, revision: string): string {
71
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/revisions/${revision}/manifest.json`;
72
+ }
73
+
74
+ export function indexKey(publicationId: string, revision: string): string {
75
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/revisions/${revision}/index.json`;
76
+ }
77
+
78
+ export function pageKey(publicationId: string, revision: string, page: number): string {
79
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/revisions/${revision}/pages/${page}.json`;
80
+ }
81
+
82
+ export function idsKey(publicationId: string, revision: string, shard: string): string {
83
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/revisions/${revision}/ids/${shard}.json`;
84
+ }
85
+
86
+ /** The lookup shard of an asset: the first two hex digits of its ID. */
87
+ export function idShard(assetId: string): string {
88
+ return assetId.slice(0, 2).toLowerCase();
89
+ }
90
+
91
+ /** Lookup shards for a revision: asset ID to position, 256 shards at most. */
92
+ export function publicIdShards(photos: readonly { assetId: string }[]): Map<string, PublicGalleryIds> {
93
+ const shards = new Map<string, PublicGalleryIds>();
94
+ photos.forEach((photo, position) => {
95
+ const shard = idShard(photo.assetId);
96
+ let ids = shards.get(shard);
97
+ if (!ids) shards.set(shard, ids = { version: 2, positions: {} });
98
+ ids.positions[photo.assetId.toLowerCase()] = position;
99
+ });
100
+ return shards;
101
+ }
102
+
103
+ export function assetKey(publicationId: string, revision: string, assetId: string, variant: 'thumbnail' | 'display'): string {
104
+ return `${PUBLIC_GALLERY_PREFIX}/${publicationId}/revisions/${revision}/assets/${assetId}/${variant}.webp`;
105
+ }
106
+
107
+ const TAKEN_AT = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/;
108
+ const MONTH = /^\d{4}-\d{2}$/;
109
+
110
+ function optionalText(value: unknown): boolean {
111
+ return value === undefined || (typeof value === 'string' && value.length > 0 && value.length <= 120);
112
+ }
113
+
114
+ function optionalMeasure(value: unknown): boolean {
115
+ return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1_000_000);
116
+ }
117
+
118
+ /** Shared details are optional; present ones must be well formed. */
119
+ export function validPhotoDetails(photo: PublicGalleryPhotoDetails): boolean {
120
+ const hasLatitude = photo.latitude !== undefined;
121
+ if (hasLatitude !== (photo.longitude !== undefined)) return false;
122
+ if (hasLatitude && !(typeof photo.latitude === 'number' && Math.abs(photo.latitude) <= 90
123
+ && typeof photo.longitude === 'number' && Math.abs(photo.longitude) <= 180)) return false;
124
+ return (photo.takenAt === undefined || (typeof photo.takenAt === 'string' && TAKEN_AT.test(photo.takenAt)))
125
+ && optionalText(photo.camera) && optionalText(photo.lens)
126
+ && optionalMeasure(photo.fNumber) && optionalMeasure(photo.exposureTime)
127
+ && optionalMeasure(photo.iso) && optionalMeasure(photo.focalLength);
128
+ }
129
+
130
+ function validPhoto(photo: PublicGalleryPhoto | undefined, ids: Set<string>): boolean {
131
+ if (!photo || !UUID.test(photo.assetId) || ids.has(photo.assetId)
132
+ || typeof photo.caption !== 'string' || photo.caption.length > 500
133
+ || typeof photo.alt !== 'string' || photo.alt.length > 500
134
+ || !Number.isSafeInteger(photo.width) || photo.width <= 0
135
+ || !Number.isSafeInteger(photo.height) || photo.height <= 0
136
+ || !validPhotoDetails(photo)) return false;
137
+ ids.add(photo.assetId);
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * Months for the spine, when every photo has a date and they run in time
143
+ * order; otherwise none, and the page shows no month headings or spine.
144
+ */
145
+ export function publicMonths(photos: readonly PublicGalleryPhotoDetails[]): PublicGalleryMonth[] | undefined {
146
+ const months: PublicGalleryMonth[] = [];
147
+ for (let position = 0; position < photos.length; position += 1) {
148
+ const month = photos[position].takenAt?.slice(0, 7);
149
+ if (!month) return undefined;
150
+ const last = months[months.length - 1];
151
+ if (last && month < last.month) return undefined;
152
+ if (last?.month === month) last.photos += 1;
153
+ else months.push({ month, photos: 1, position });
154
+ }
155
+ return months.length ? months : undefined;
156
+ }
157
+
158
+ export function parsePublicManifest(value: unknown): PublicGalleryManifest | null {
159
+ if (!value || typeof value !== 'object') return null;
160
+ const manifest = value as Partial<PublicGalleryManifest>;
161
+ if (manifest.version !== 1 || typeof manifest.title !== 'string' || manifest.title.length > 120
162
+ || typeof manifest.description !== 'string' || manifest.description.length > 500
163
+ || !Array.isArray(manifest.photos) || manifest.photos.length === 0 || manifest.photos.length > 10_000) return null;
164
+ const ids = new Set<string>();
165
+ for (const photo of manifest.photos) if (!validPhoto(photo, ids)) return null;
166
+ return manifest as PublicGalleryManifest;
167
+ }
168
+
169
+ export function parsePublicIndex(value: unknown): PublicGalleryIndex | null {
170
+ if (!value || typeof value !== 'object') return null;
171
+ const index = value as Partial<PublicGalleryIndex>;
172
+ if (!(index.version === 2 && typeof index.title === 'string' && index.title.length <= 120
173
+ && typeof index.description === 'string' && index.description.length <= 500
174
+ && Number.isSafeInteger(index.photoCount) && index.photoCount! > 0 && index.photoCount! <= 10_000)) return null;
175
+ if (index.ids !== undefined && index.ids !== true) return null;
176
+ if (index.months !== undefined) {
177
+ if (!Array.isArray(index.months) || index.months.length === 0) return null;
178
+ let previous = '';
179
+ let covered = 0;
180
+ for (const month of index.months) {
181
+ if (!month || typeof month.month !== 'string' || !MONTH.test(month.month) || month.month <= previous
182
+ || !Number.isSafeInteger(month.photos) || month.photos <= 0
183
+ || month.position !== covered) return null;
184
+ previous = month.month;
185
+ covered += month.photos;
186
+ }
187
+ if (covered !== index.photoCount) return null;
188
+ }
189
+ return index as PublicGalleryIndex;
190
+ }
191
+
192
+ export function parsePublicPage(value: unknown): PublicGalleryPage | null {
193
+ if (!value || typeof value !== 'object') return null;
194
+ const page = value as Partial<PublicGalleryPage>;
195
+ if (page.version !== 2 || !Array.isArray(page.photos) || page.photos.length < 1 || page.photos.length > 100) return null;
196
+ const ids = new Set<string>();
197
+ for (const photo of page.photos) if (!validPhoto(photo, ids)) return null;
198
+ return page as PublicGalleryPage;
199
+ }
200
+
201
+ export function parsePublicIds(value: unknown): PublicGalleryIds | null {
202
+ if (!value || typeof value !== 'object') return null;
203
+ const ids = value as Partial<PublicGalleryIds>;
204
+ if (ids.version !== 2 || !ids.positions || typeof ids.positions !== 'object') return null;
205
+ const entries = Object.entries(ids.positions);
206
+ if (entries.length === 0 || entries.length > 10_000) return null;
207
+ for (const [assetId, position] of entries) {
208
+ if (!UUID.test(assetId) || !Number.isSafeInteger(position) || position < 0 || position >= 10_000) return null;
209
+ }
210
+ return ids as PublicGalleryIds;
211
+ }
@@ -0,0 +1,237 @@
1
+ import { render } from 'svelte/server';
2
+ import { activeKey, assetKey, idShard, idsKey, indexKey, manifestKey, pageKey, parsePublicIds, parsePublicIndex, parsePublicManifest, parsePublicPage, UUID, type PublicGalleryIndex, type PublicGalleryPage, type PublicGalleryPhoto } from './contract.js';
3
+ import { clientAssets } from './client-assets.js';
4
+ import { PAGE_SIZE, type PublicGalleryData, type PublicPageResponse, type PublicPhoto } from './app/types.js';
5
+ import App from './app/App.svelte';
6
+
7
+ export interface PublicGalleryEnv {
8
+ GALLERY_PUBLIC: {
9
+ get(key: string): Promise<{ json(): Promise<unknown>; body: ReadableStream<Uint8Array> } | null>;
10
+ };
11
+ PUBLIC_ORIGIN: string;
12
+ }
13
+
14
+ const PAGE = /^\/g\/([0-9a-f-]{36})\/?$/i;
15
+ const DETAIL = /^\/g\/([0-9a-f-]{36})\/photos\/([0-9a-f-]{36})\/?$/i;
16
+ /** Photo addresses shared before they dropped the revision and position; they redirect to `DETAIL`. */
17
+ const LEGACY_DETAIL = /^\/g\/([0-9a-f-]{36})\/photos\/([0-9a-f-]{36})\/(0|[1-9][0-9]{0,4})\/([0-9a-f-]{36})\/?$/i;
18
+ const ASSET = /^\/g\/([0-9a-f-]{36})\/assets\/([0-9a-f-]{36})\/(0|[1-9][0-9]{0,4})\/([0-9a-f-]{36})\/(thumbnail|display)\.webp$/i;
19
+ const PAGE_JSON = /^\/g\/([0-9a-f-]{36})\/pages\/(0|[1-9][0-9]{0,4})\.json$/i;
20
+ const BUNDLE = /^\/_app\/[A-Za-z0-9_.-]+$/;
21
+
22
+ /** Images may rest in a browser briefly; everything else is re-read so an unpublish shows at once. */
23
+ const IMAGE_CACHE_CONTROL = 'private, max-age=60';
24
+
25
+ /** Only this Worker's own bundle runs; styles may be inline for layout sizes. */
26
+ const CONTENT_SECURITY_POLICY = "default-src 'none'; img-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
27
+
28
+ function response(body: BodyInit | null, status = 200, contentType = 'text/html; charset=utf-8', cacheControl = 'no-store', extra: Record<string, string> = {}): Response {
29
+ return new Response(body, { status, headers: {
30
+ ...extra,
31
+ 'Content-Type': contentType,
32
+ 'Cache-Control': cacheControl,
33
+ 'X-Content-Type-Options': 'nosniff',
34
+ 'X-Robots-Tag': 'noindex, nofollow, noarchive',
35
+ 'Referrer-Policy': 'no-referrer',
36
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
37
+ } });
38
+ }
39
+
40
+ interface ActiveGallery {
41
+ revision: string;
42
+ index: PublicGalleryIndex;
43
+ /** Photos of a whole v1 manifest, which has no page shards. */
44
+ photos?: PublicGalleryPhoto[];
45
+ }
46
+
47
+ async function readJson(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], key: string): Promise<unknown> {
48
+ const object = await bucket.get(key);
49
+ if (!object) return null;
50
+ try { return await object.json(); } catch { return null; }
51
+ }
52
+
53
+ /**
54
+ * Files of one revision never change, so this isolate keeps the recent ones.
55
+ * The active pointer is not among them: it is read on every request.
56
+ */
57
+ const revisionFiles = new Map<string, unknown>();
58
+ const REVISION_FILE_LIMIT = 256;
59
+
60
+ async function readRevisionJson(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], key: string): Promise<unknown> {
61
+ if (revisionFiles.has(key)) return revisionFiles.get(key);
62
+ const value = await readJson(bucket, key);
63
+ if (value !== null) {
64
+ if (revisionFiles.size >= REVISION_FILE_LIMIT) revisionFiles.delete(revisionFiles.keys().next().value!);
65
+ revisionFiles.set(key, value);
66
+ }
67
+ return value;
68
+ }
69
+
70
+ async function activeGallery(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string): Promise<ActiveGallery | null> {
71
+ const pointer = await readJson(bucket, activeKey(publicationId));
72
+ if (!pointer || typeof pointer !== 'object' || !('revision' in pointer) || !UUID.test(String(pointer.revision))) return null;
73
+ const revision = String(pointer.revision);
74
+ const index = parsePublicIndex(await readRevisionJson(bucket, indexKey(publicationId, revision)));
75
+ if (index) return { revision, index };
76
+ // Revisions activated before index.json keep either a sharded index or a whole v1 manifest here.
77
+ const legacy = await readRevisionJson(bucket, manifestKey(publicationId, revision));
78
+ const legacyIndex = parsePublicIndex(legacy);
79
+ if (legacyIndex) return { revision, index: legacyIndex };
80
+ const manifest = parsePublicManifest(legacy);
81
+ return manifest ? {
82
+ revision, photos: manifest.photos,
83
+ index: { version: 2, title: manifest.title, description: manifest.description, photoCount: manifest.photos.length },
84
+ } : null;
85
+ }
86
+
87
+ async function galleryPage(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string, gallery: ActiveGallery, page: number): Promise<PublicGalleryPage | null> {
88
+ if (page < 0 || page >= Math.ceil(gallery.index.photoCount / PAGE_SIZE)) return null;
89
+ if (gallery.photos) return { version: 2, photos: gallery.photos.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) };
90
+ const shard = parsePublicPage(await readRevisionJson(bucket, pageKey(publicationId, gallery.revision, page)));
91
+ return shard?.photos.length === Math.min(PAGE_SIZE, gallery.index.photoCount - page * PAGE_SIZE) ? shard : null;
92
+ }
93
+
94
+ async function photoAt(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string, gallery: ActiveGallery, position: number): Promise<PublicGalleryPhoto | null> {
95
+ if (position < 0 || position >= gallery.index.photoCount) return null;
96
+ const shard = await galleryPage(bucket, publicationId, gallery, Math.floor(position / PAGE_SIZE));
97
+ return shard?.photos[position % PAGE_SIZE] ?? null;
98
+ }
99
+
100
+ /**
101
+ * Where a photo sits in the reviewed order. Revisions with lookup shards read
102
+ * one; a revision published before them is searched page by page.
103
+ */
104
+ async function positionOf(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string, gallery: ActiveGallery, assetId: string): Promise<number | null> {
105
+ if (gallery.photos) {
106
+ const found = gallery.photos.findIndex((photo) => photo.assetId === assetId);
107
+ return found < 0 ? null : found;
108
+ }
109
+ if (gallery.index.ids) {
110
+ const ids = parsePublicIds(await readRevisionJson(bucket, idsKey(publicationId, gallery.revision, idShard(assetId))));
111
+ return ids?.positions[assetId] ?? null;
112
+ }
113
+ for (let page = 0; page < Math.ceil(gallery.index.photoCount / PAGE_SIZE); page += 1) {
114
+ const shard = await galleryPage(bucket, publicationId, gallery, page);
115
+ const found = shard?.photos.findIndex((photo) => photo.assetId === assetId) ?? -1;
116
+ if (found >= 0) return page * PAGE_SIZE + found;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function escapeHtml(value: string): string {
122
+ return value.replace(/[&<>"']/g, (character) => ({
123
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
124
+ })[character]!);
125
+ }
126
+
127
+ /** The photos of one shard, with their positions in the reviewed order. */
128
+ function positioned(shard: PublicGalleryPage, page: number): PublicPhoto[] {
129
+ return shard.photos.map((photo, offset) => ({ ...photo, position: page * PAGE_SIZE + offset }));
130
+ }
131
+
132
+ /**
133
+ * The page is rendered on the server, so it reads and links without a
134
+ * script; the browser bundle then takes over the same markup.
135
+ */
136
+ function renderDocument(data: PublicGalleryData, documentTitle: string): string {
137
+ const { head, body } = render(App, { props: { data } });
138
+ // Script-free JSON for the browser, safe to embed in HTML.
139
+ const state = JSON.stringify(data).replace(/</g, '\\u003c');
140
+ const styles = clientAssets.styles.map((style) => `<link rel="stylesheet" href="${style.path}">`).join('');
141
+ const script = clientAssets.script ? `<script type="module" src="${clientAssets.script.path}"></script>` : '';
142
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="robots" content="noindex,nofollow,noarchive"><title>${escapeHtml(documentTitle)}</title>${styles}${head}${script}</head><body><div id="app">${body}</div><script type="application/json" id="gallery-data">${state}</script></body></html>`;
143
+ }
144
+
145
+ async function galleryDocument(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string, gallery: ActiveGallery, page: number): Promise<string | null> {
146
+ const shard = await galleryPage(bucket, publicationId, gallery, page);
147
+ if (!shard) return null;
148
+ return renderDocument({
149
+ publicationId, revision: gallery.revision, title: gallery.index.title, description: gallery.index.description,
150
+ photoCount: gallery.index.photoCount, months: gallery.index.months ?? null,
151
+ firstPage: page, photos: positioned(shard, page), open: null,
152
+ }, gallery.index.title);
153
+ }
154
+
155
+ /** A photo opened over its page; a photo near the page's edge brings the neighbouring page for the filmstrip. */
156
+ async function photoDocument(bucket: PublicGalleryEnv['GALLERY_PUBLIC'], publicationId: string, gallery: ActiveGallery, position: number): Promise<string | null> {
157
+ const page = Math.floor(position / PAGE_SIZE);
158
+ const pages = [page];
159
+ const lastPage = Math.ceil(gallery.index.photoCount / PAGE_SIZE) - 1;
160
+ if (position % PAGE_SIZE < 8 && page > 0) pages.unshift(page - 1);
161
+ if (position % PAGE_SIZE > PAGE_SIZE - 9 && page < lastPage) pages.push(page + 1);
162
+ const shards = await Promise.all(pages.map((number) => galleryPage(bucket, publicationId, gallery, number)));
163
+ if (shards.some((shard) => !shard)) return null;
164
+ const photos = shards.flatMap((shard, index) => positioned(shard!, pages[index]));
165
+ const photo = photos.find((each) => each.position === position)!;
166
+ return renderDocument({
167
+ publicationId, revision: gallery.revision, title: gallery.index.title, description: gallery.index.description,
168
+ photoCount: gallery.index.photoCount, months: gallery.index.months ?? null,
169
+ firstPage: pages[0], photos, open: position,
170
+ }, photo.caption || gallery.index.title);
171
+ }
172
+
173
+ export async function handlePublicGalleryRequest(request: Request, env: PublicGalleryEnv): Promise<Response> {
174
+ const url = new URL(request.url);
175
+ if (url.origin !== env.PUBLIC_ORIGIN) return response('Not found', 404, 'text/plain; charset=utf-8');
176
+ if (request.method !== 'GET' && request.method !== 'HEAD') return response('Not found', 404, 'text/plain; charset=utf-8');
177
+ if (url.pathname === '/robots.txt') return response(request.method === 'HEAD' ? null : 'User-agent: *\nDisallow: /\n', 200, 'text/plain; charset=utf-8');
178
+ if (BUNDLE.test(url.pathname)) {
179
+ // The browser bundle is named by its content, so it can be kept for good.
180
+ const file = [clientAssets.script, ...clientAssets.styles].find((asset) => asset?.path === url.pathname);
181
+ if (!file) return response('Not found', 404, 'text/plain; charset=utf-8');
182
+ return response(request.method === 'HEAD' ? null : file.body, 200, file.type, 'public, max-age=31536000, immutable');
183
+ }
184
+ const pageMatch = url.pathname.match(PAGE);
185
+ const detailMatch = url.pathname.match(DETAIL);
186
+ const legacyMatch = url.pathname.match(LEGACY_DETAIL);
187
+ const assetMatch = url.pathname.match(ASSET);
188
+ const jsonMatch = url.pathname.match(PAGE_JSON);
189
+ if (!pageMatch && !detailMatch && !legacyMatch && !assetMatch && !jsonMatch) return response('Not found', 404, 'text/plain; charset=utf-8');
190
+ const publicationId = (pageMatch?.[1] ?? detailMatch?.[1] ?? legacyMatch?.[1] ?? assetMatch?.[1] ?? jsonMatch?.[1])!;
191
+ if (!UUID.test(publicationId)) return response('Not found', 404, 'text/plain; charset=utf-8');
192
+ let active;
193
+ try { active = await activeGallery(env.GALLERY_PUBLIC, publicationId); }
194
+ catch { return response('Unavailable', 503, 'text/plain; charset=utf-8'); }
195
+ if (!active) return response('Not found', 404, 'text/plain; charset=utf-8');
196
+ try {
197
+ if (pageMatch) {
198
+ const rawPage = url.searchParams.get('page') ?? '0';
199
+ if (!/^(0|[1-9]\d{0,4})$/.test(rawPage)) return response('Not found', 404, 'text/plain; charset=utf-8');
200
+ const html = await galleryDocument(env.GALLERY_PUBLIC, publicationId, active, Number(rawPage));
201
+ if (!html) return response('Not found', 404, 'text/plain; charset=utf-8');
202
+ return response(request.method === 'HEAD' ? null : html);
203
+ }
204
+ if (jsonMatch) {
205
+ const page = Number(jsonMatch[2]);
206
+ const shard = await galleryPage(env.GALLERY_PUBLIC, publicationId, active, page);
207
+ if (!shard) return response('Not found', 404, 'text/plain; charset=utf-8');
208
+ const body: PublicPageResponse = { revision: active.revision, page, photos: positioned(shard, page) };
209
+ return response(request.method === 'HEAD' ? null : JSON.stringify(body), 200, 'application/json; charset=utf-8');
210
+ }
211
+ if (detailMatch || legacyMatch) {
212
+ const assetId = (detailMatch?.[2] ?? legacyMatch![4]).toLowerCase();
213
+ const position = await positionOf(env.GALLERY_PUBLIC, publicationId, active, assetId);
214
+ if (position === null) return response('Not found', 404, 'text/plain; charset=utf-8');
215
+ if (legacyMatch) {
216
+ return response(null, 302, 'text/plain; charset=utf-8', 'no-store', { Location: `/g/${publicationId}/photos/${assetId}` });
217
+ }
218
+ const photo = await photoAt(env.GALLERY_PUBLIC, publicationId, active, position);
219
+ if (!photo || photo.assetId !== assetId) return response('Not found', 404, 'text/plain; charset=utf-8');
220
+ const html = await photoDocument(env.GALLERY_PUBLIC, publicationId, active, position);
221
+ if (!html) return response('Not found', 404, 'text/plain; charset=utf-8');
222
+ return response(request.method === 'HEAD' ? null : html);
223
+ }
224
+ const [, , revision, rawPosition, assetId, variant] = assetMatch!;
225
+ if (revision !== active.revision || !UUID.test(assetId)) return response('Not found', 404, 'text/plain; charset=utf-8');
226
+ const photo = await photoAt(env.GALLERY_PUBLIC, publicationId, active, Number(rawPosition));
227
+ if (!photo || photo.assetId !== assetId) return response('Not found', 404, 'text/plain; charset=utf-8');
228
+ const object = await env.GALLERY_PUBLIC.get(assetKey(publicationId, revision, assetId, variant as 'thumbnail' | 'display'));
229
+ if (!object) return response('Not found', 404, 'text/plain; charset=utf-8');
230
+ return response(request.method === 'HEAD' ? null : object.body, 200, 'image/webp', IMAGE_CACHE_CONTROL);
231
+ } catch (error) {
232
+ console.error('[Gallery] Public request failed', error);
233
+ return response('Unavailable', 503, 'text/plain; charset=utf-8');
234
+ }
235
+ }
236
+
237
+ export default { fetch: handlePublicGalleryRequest };
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": [
7
+ "ES2022",
8
+ "DOM"
9
+ ],
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "types": []
14
+ },
15
+ "include": [
16
+ "index.ts",
17
+ "contract.ts",
18
+ "client-assets.ts",
19
+ "app/*.ts"
20
+ ]
21
+ }
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import type { GalleryAlbumSummary } from '../../data/gallery.js';
3
+ import { galleryRendition } from '../../gallery/file-store.js';
4
+ import IconImage from '~icons/mdi/image-multiple-outline';
5
+
6
+ let { album, tenantId, logId, onOpen }: {
7
+ album: GalleryAlbumSummary;
8
+ tenantId: string;
9
+ logId: string;
10
+ onOpen: () => void;
11
+ } = $props();
12
+
13
+ let imageUrl = $state('');
14
+ $effect(() => {
15
+ imageUrl = '';
16
+ if (!album.effective_cover_photo_id || !album.cover_file_version_id || !tenantId || !logId) return;
17
+ let cancelled = false;
18
+ let objectUrl = '';
19
+ void galleryRendition({
20
+ tenantId, logId, photoId: album.effective_cover_photo_id,
21
+ fileVersionId: album.cover_file_version_id,
22
+ }, 'thumbnail').then((blob) => {
23
+ if (cancelled) return;
24
+ objectUrl = URL.createObjectURL(blob);
25
+ imageUrl = objectUrl;
26
+ }).catch(() => undefined);
27
+ return () => {
28
+ cancelled = true;
29
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
30
+ };
31
+ });
32
+ </script>
33
+
34
+ <button type="button" onclick={onOpen} class="group min-w-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] rounded-xl">
35
+ <span class="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-xl bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]">
36
+ {#if imageUrl}<img src={imageUrl} alt="" class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.025] motion-reduce:transition-none" />
37
+ {:else}<IconImage class="h-8 w-8" />{/if}
38
+ </span>
39
+ <span class="mt-2 block truncate text-sm font-semibold text-[var(--stor-ink)]">{album.title}</span>
40
+ <span class="block text-xs text-[var(--stor-muted-text)]">{album.photo_count} {album.photo_count === 1 ? 'photo' : 'photos'}</span>
41
+ </button>