@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,448 @@
1
+ <script lang="ts">
2
+ import { onMount, tick, untrack } from 'svelte';
3
+ import GalleryTile from '../../src/components/gallery/view/GalleryTile.svelte';
4
+ import JustifiedRows from '../../src/components/gallery/view/JustifiedRows.svelte';
5
+ import MonthHeading from '../../src/components/gallery/view/MonthHeading.svelte';
6
+ import GallerySpine from '../../src/components/gallery/view/GallerySpine.svelte';
7
+ import ViewerFrame, { type ViewerFilmItem } from '../../src/components/gallery/view/ViewerFrame.svelte';
8
+ import { formatPhotoCount, formatPhotoDate, formatTileDay, photoDetailRows } from '../../src/gallery/format.js';
9
+ import { tileRatio } from '../../src/gallery/layout.js';
10
+ import { galleryGeometry } from '../../src/gallery/timeline.js';
11
+ import { publicDetailSource } from '../../src/gallery/public-details.js';
12
+ import {
13
+ PAGE_SIZE, assetPath, galleryPath, openedAsset, pagePath, photoPath,
14
+ type PublicGalleryData, type PublicPageResponse, type PublicPhoto,
15
+ } from './types.js';
16
+
17
+ let { data }: { data: PublicGalleryData } = $props();
18
+
19
+ // One page load shows one revision; a newer one reloads the page.
20
+ const gallery = untrack(() => data);
21
+ const id = gallery.publicationId;
22
+ const revision = gallery.revision;
23
+ const months = gallery.months;
24
+ const lastPage = Math.ceil(gallery.photoCount / PAGE_SIZE) - 1;
25
+ const monthPhotos = new Map((months ?? []).map((month) => [month.month, month.photos]));
26
+ const spineMonths = months ? months.map((month) => ({ month: month.month, photos: month.photos, peak: Math.max(...months.map((each) => each.photos)) })) : [];
27
+
28
+ let photos = $state.raw<PublicPhoto[]>(gallery.photos);
29
+ let firstPage = $state(gallery.firstPage);
30
+ let open = $state<number | null>(gallery.open);
31
+ let width = $state(0);
32
+ let detailsOpen = $state(false);
33
+ let scrolling = $state(false);
34
+ let inView = $state({ first: '', last: '' });
35
+ let more = $state<HTMLElement>();
36
+ let loading = false;
37
+
38
+ const loadedLastPage = $derived(firstPage + Math.ceil(photos.length / PAGE_SIZE) - 1);
39
+ const showCover = $derived(firstPage === 0 && photos.length > 0);
40
+ const geometry = $derived(galleryGeometry(width, 1));
41
+ /** Consecutive photos of one month share a heading; undated galleries are one run. */
42
+ const groups = $derived.by(() => {
43
+ const listed = showCover ? photos.slice(1) : photos;
44
+ if (!months) return listed.length ? [{ key: 'all', month: '', photos: listed }] : [];
45
+ const runs: Array<{ key: string; month: string; photos: PublicPhoto[] }> = [];
46
+ for (const photo of listed) {
47
+ const month = photo.takenAt?.slice(0, 7) ?? '';
48
+ const run = runs[runs.length - 1];
49
+ if (run?.month === month) run.photos.push(photo);
50
+ else runs.push({ key: `${month}:${photo.position}`, month, photos: [photo] });
51
+ }
52
+ return runs;
53
+ });
54
+
55
+ const photoAt = (position: number) => photos[position - firstPage * PAGE_SIZE] ?? null;
56
+ const asset = (photo: PublicPhoto, variant: 'thumbnail' | 'display') => assetPath(id, revision, photo, variant);
57
+ const pageOf = (position: number) => Math.floor(position / PAGE_SIZE);
58
+
59
+ const current = $derived(open === null ? null : photoAt(open));
60
+ const currentTitle = $derived(current ? (current.takenAt ? formatPhotoDate(publicDetailSource(current)) : current.caption || gallery.title) : '');
61
+ const currentDetails = $derived(current ? photoDetailRows(publicDetailSource(current), { file: false }) : []);
62
+ const film = $derived.by<ViewerFilmItem[]>(() => {
63
+ if (!current) return [];
64
+ const from = Math.max(0, current.position - firstPage * PAGE_SIZE - 8);
65
+ return photos.slice(from, from + 17).map((photo) => ({
66
+ id: String(photo.position), ratio: tileRatio(photo), label: `photo ${photo.position + 1}`, href: photoPath(id, photo),
67
+ }));
68
+ });
69
+ const step = (position: number) => (position >= 0 && position < gallery.photoCount
70
+ ? { id: String(position), href: photoAt(position) ? photoPath(id, photoAt(position)!) : undefined }
71
+ : null);
72
+
73
+ // ---------------------------------------------------------------- pages
74
+
75
+ async function readPage(page: number): Promise<PublicPhoto[]> {
76
+ const response = await fetch(pagePath(id, page), { headers: { Accept: 'application/json' } });
77
+ if (!response.ok) throw new Error('Page unavailable');
78
+ const value = await response.json() as PublicPageResponse;
79
+ // The owner published a newer version; show it from the start.
80
+ if (value.revision !== revision) { location.reload(); throw new Error('Revision changed'); }
81
+ return value.photos;
82
+ }
83
+
84
+ async function loadNext(): Promise<void> {
85
+ if (loading || loadedLastPage >= lastPage) return;
86
+ loading = true;
87
+ try { photos = [...photos, ...await readPage(loadedLastPage + 1)]; }
88
+ catch { /* The link below still leads to the next page. */ }
89
+ finally { loading = false; }
90
+ }
91
+
92
+ async function loadPrevious(): Promise<void> {
93
+ if (loading || firstPage === 0) return;
94
+ loading = true;
95
+ try {
96
+ const earlier = await readPage(firstPage - 1);
97
+ const before = document.documentElement.scrollHeight;
98
+ photos = [...earlier, ...photos];
99
+ firstPage -= 1;
100
+ await tick();
101
+ // Keep what was on screen in place as the earlier photos arrive above it.
102
+ window.scrollBy(0, document.documentElement.scrollHeight - before);
103
+ } catch { /* The link above still leads to the earlier page. */ }
104
+ finally { loading = false; }
105
+ }
106
+
107
+ /** Shows one page on its own, for a jump far from what is loaded. */
108
+ async function showPage(page: number): Promise<void> {
109
+ loading = true;
110
+ try {
111
+ photos = await readPage(page);
112
+ firstPage = page;
113
+ await tick();
114
+ } finally { loading = false; }
115
+ }
116
+
117
+ /** Loads the page holding a photo next to what is already shown. */
118
+ async function ensureLoaded(position: number): Promise<void> {
119
+ const page = pageOf(position);
120
+ if (page === firstPage - 1) await loadPrevious();
121
+ else if (page === loadedLastPage + 1) await loadNext();
122
+ else if (page < firstPage || page > loadedLastPage) await showPage(page);
123
+ }
124
+
125
+ // ---------------------------------------------------------------- the viewer
126
+
127
+ let returnFocus: number | null = null;
128
+
129
+ function openPhoto(event: MouseEvent, position: number): void {
130
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
131
+ event.preventDefault();
132
+ returnFocus = position;
133
+ open = position;
134
+ history.pushState({ publicGallery: 'photo' }, '', photoPath(id, photoAt(position)!));
135
+ }
136
+
137
+ async function navigate(target: string): Promise<void> {
138
+ const position = Number(target);
139
+ await ensureLoaded(position);
140
+ const photo = photoAt(position);
141
+ if (!photo) return;
142
+ open = position;
143
+ returnFocus = position;
144
+ history.replaceState(history.state, '', photoPath(id, photo));
145
+ }
146
+
147
+ async function closeViewer(): Promise<void> {
148
+ const position = open;
149
+ open = null;
150
+ if (history.state?.publicGallery === 'photo') history.back();
151
+ else history.replaceState(null, '', galleryPath(id, firstPage));
152
+ await tick();
153
+ const tile = position === null ? null : document.getElementById(`photo-${position}`);
154
+ if (tile) {
155
+ const rect = tile.getBoundingClientRect();
156
+ if (rect.top < 60 || rect.bottom > innerHeight) tile.scrollIntoView({ block: 'center' });
157
+ tile.focus({ preventScroll: true });
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------- the spine
162
+
163
+ async function jumpToMonth(month: string): Promise<void> {
164
+ const target = months?.find((entry) => entry.month === month);
165
+ if (!target) return;
166
+ const page = pageOf(target.position);
167
+ if (page < firstPage || page > loadedLastPage) await showPage(page);
168
+ await tick();
169
+ const group = document.querySelector<HTMLElement>(`[data-month="${month}"]`);
170
+ if (group) window.scrollTo({ top: group.getBoundingClientRect().top + scrollY - 4 });
171
+ else if (target.position === 0) window.scrollTo({ top: 0 });
172
+ }
173
+
174
+ async function jumpToLatest(): Promise<void> {
175
+ if (loadedLastPage < lastPage) await showPage(lastPage);
176
+ await tick();
177
+ window.scrollTo({ top: document.documentElement.scrollHeight });
178
+ }
179
+
180
+ let frame = 0;
181
+ let scrollTimer: ReturnType<typeof setTimeout> | undefined;
182
+ function followScroll(): void {
183
+ scrolling = true;
184
+ clearTimeout(scrollTimer);
185
+ scrollTimer = setTimeout(() => scrolling = false, 1400);
186
+ cancelAnimationFrame(frame);
187
+ frame = requestAnimationFrame(() => {
188
+ const shown = [...document.querySelectorAll<HTMLElement>('[data-month]')]
189
+ .filter((group) => { const rect = group.getBoundingClientRect(); return rect.bottom > 60 && rect.top < innerHeight; })
190
+ .map((group) => group.dataset.month ?? '');
191
+ const top = scrollY < 40 && showCover && months ? months[0].month : shown[0];
192
+ inView = { first: top ?? '', last: shown[shown.length - 1] ?? top ?? '' };
193
+ });
194
+ }
195
+
196
+ onMount(() => {
197
+ detailsOpen = matchMedia('(min-width: 1024px)').matches;
198
+ followScroll();
199
+ const popped = () => {
200
+ const asset = openedAsset(location.pathname);
201
+ if (asset === null) { open = null; return; }
202
+ const shown = photos.find((photo) => photo.assetId === asset);
203
+ // A photo outside the loaded pages comes from the server, which renders its address.
204
+ if (shown) open = shown.position;
205
+ else location.reload();
206
+ };
207
+ addEventListener('popstate', popped);
208
+ const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) void loadNext(); }, { rootMargin: '1200px 0px' });
209
+ if (more) observer.observe(more);
210
+ return () => {
211
+ removeEventListener('popstate', popped);
212
+ observer.disconnect();
213
+ cancelAnimationFrame(frame);
214
+ clearTimeout(scrollTimer);
215
+ };
216
+ });
217
+
218
+ // The page behind stays put while a photo is open.
219
+ $effect(() => {
220
+ if (open === null) return;
221
+ const root = document.documentElement;
222
+ const previous = root.style.overflow;
223
+ root.style.overflow = 'hidden';
224
+ return () => { root.style.overflow = previous; };
225
+ });
226
+
227
+ function keydown(event: KeyboardEvent): void {
228
+ if (event.key === 'Escape' && open !== null) {
229
+ event.preventDefault();
230
+ void closeViewer();
231
+ }
232
+ }
233
+ </script>
234
+
235
+ <svelte:window onscroll={followScroll} onresize={followScroll} onkeydown={keydown} />
236
+
237
+ <div class="public" class:dated={Boolean(months)}>
238
+ {#if firstPage === 0}
239
+ <header class="public-head">
240
+ <h1>{gallery.title}</h1>
241
+ <div class="public-intro">
242
+ {#if gallery.description}<p class="public-dek">{gallery.description}</p>{/if}
243
+ <p class="public-meta">{formatPhotoCount(gallery.photoCount)}{' · '}Shared with a link</p>
244
+ </div>
245
+ </header>
246
+ {#if showCover}
247
+ {@const cover = photos[0]}
248
+ <a
249
+ id="photo-0"
250
+ class="public-cover"
251
+ href={photoPath(id, cover)}
252
+ style:--r={tileRatio(cover)}
253
+ aria-label={cover.caption || cover.alt}
254
+ onclick={(event) => openPhoto(event, 0)}
255
+ ><img src={asset(cover, 'display')} alt={cover.alt} /></a>
256
+ {/if}
257
+ {:else}
258
+ <header class="public-head compact">
259
+ <a class="public-title-link" href={galleryPath(id)}>{gallery.title}</a>
260
+ <p class="public-meta">Photos {firstPage * PAGE_SIZE + 1}–{firstPage * PAGE_SIZE + photos.length} of {gallery.photoCount}</p>
261
+ <a class="public-more-link" href={galleryPath(id, firstPage - 1)} onclick={(event) => { event.preventDefault(); void loadPrevious(); }}>Show earlier photos</a>
262
+ </header>
263
+ {/if}
264
+
265
+ <div class="public-body" bind:clientWidth={width}>
266
+ {#each groups as group (group.key)}
267
+ <section class="public-group" data-month={group.month || undefined}>
268
+ {#if group.month}<MonthHeading month={group.month} count={monthPhotos.get(group.month) ?? group.photos.length} />{/if}
269
+ <JustifiedRows items={group.photos} key={(photo) => photo.assetId} width={width} rowHeight={geometry.rowHeight} gap={geometry.gap}>
270
+ {#snippet tile(photo, size)}
271
+ <GalleryTile
272
+ id={`photo-${photo.position}`}
273
+ href={photoPath(id, photo)}
274
+ src={asset(photo, 'thumbnail')}
275
+ alt={photo.alt}
276
+ label={photo.caption || photo.alt}
277
+ {size}
278
+ day={photo.takenAt ? formatTileDay(photo.takenAt) : ''}
279
+ onOpen={(event) => openPhoto(event, photo.position)}
280
+ />
281
+ {/snippet}
282
+ </JustifiedRows>
283
+ </section>
284
+ {/each}
285
+ <nav bind:this={more} class="public-pages" aria-label="More photos">
286
+ {#if loadedLastPage < lastPage}
287
+ <a class="public-more-link" href={galleryPath(id, loadedLastPage + 1)} onclick={(event) => { event.preventDefault(); void loadNext(); }}>More photos</a>
288
+ {/if}
289
+ </nav>
290
+ </div>
291
+
292
+ <footer class="public-foot"><span>Shared with a link</span><span>{formatPhotoCount(gallery.photoCount)}</span></footer>
293
+
294
+ {#if months}
295
+ <div class="public-spine">
296
+ <GallerySpine months={spineMonths} first={inView.first} last={inView.last} active={scrolling} onJump={(month) => void jumpToMonth(month)} onLatest={() => void jumpToLatest()} />
297
+ </div>
298
+ {/if}
299
+ </div>
300
+
301
+ {#if current}
302
+ <div class="public-viewer" role="dialog" aria-modal="true" aria-label={currentTitle}>
303
+ <ViewerFrame
304
+ title={currentTitle}
305
+ subtitle={`${current.position + 1} of ${gallery.photoCount}`}
306
+ imageKey={String(current.position)}
307
+ imageUrl={asset(current, 'display')}
308
+ backdropUrl={asset(current, 'thumbnail')}
309
+ alt={current.alt}
310
+ caption={current.takenAt ? current.caption : ''}
311
+ older={step(current.position - 1)}
312
+ newer={step(current.position + 1)}
313
+ {film}
314
+ currentId={String(current.position)}
315
+ details={currentDetails}
316
+ bind:detailsOpen
317
+ closeHref={`${galleryPath(id, pageOf(current.position))}#photo-${current.position}`}
318
+ onClose={() => void closeViewer()}
319
+ onNavigate={(target) => void navigate(target)}
320
+ >
321
+ {#snippet thumb(item)}
322
+ {@const shown = photoAt(Number(item.id))}
323
+ {#if shown}<img src={asset(shown, 'thumbnail')} alt="" loading="lazy" />{/if}
324
+ {/snippet}
325
+ </ViewerFrame>
326
+ </div>
327
+ {/if}
328
+
329
+ <style>
330
+ :global(:root) {
331
+ color-scheme: light dark;
332
+ --stor-font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
333
+ --stor-font-serif: ui-serif, 'New York', 'Iowan Old Style', Charter, 'Bitstream Charter', Georgia, serif;
334
+ --stor-bg: #f4f4f1;
335
+ --stor-surface: #f4f4f1;
336
+ --stor-surface-muted: #e4e4df;
337
+ --stor-surface-raised: #ffffff;
338
+ --stor-ink: #1c1d1b;
339
+ --stor-muted-text: #6b6d67;
340
+ --stor-border: #d9d9d3;
341
+ --stor-accent: #1c1d1b;
342
+ --stor-accent-soft: #e4e4df;
343
+ --stor-accent-strong: #1c1d1b;
344
+ --stor-action-text: #ffffff;
345
+ --stor-focus: #1c1d1b;
346
+ --stor-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06);
347
+ --stor-shadow-md: 0 2px 6px rgb(0 0 0 / 0.04), 0 12px 36px rgb(0 0 0 / 0.1);
348
+ }
349
+ @media (prefers-color-scheme: dark) {
350
+ :global(:root) {
351
+ --stor-bg: #121311;
352
+ --stor-surface: #121311;
353
+ --stor-surface-muted: #20211e;
354
+ --stor-surface-raised: #2a2b28;
355
+ --stor-ink: #ebe9e3;
356
+ --stor-muted-text: #9b9992;
357
+ --stor-border: #2b2c29;
358
+ --stor-accent: #ebe9e3;
359
+ --stor-accent-soft: #2b2c29;
360
+ --stor-accent-strong: #ebe9e3;
361
+ --stor-action-text: #121311;
362
+ --stor-focus: #ebe9e3;
363
+ }
364
+ }
365
+ :global(body) {
366
+ margin: 0;
367
+ background: var(--stor-bg);
368
+ color: var(--stor-ink);
369
+ font: 400 15px/1.5 var(--stor-font-sans);
370
+ -webkit-font-smoothing: antialiased;
371
+ }
372
+ :global(*), :global(*::before), :global(*::after) { box-sizing: border-box; }
373
+ .public { max-width: 1440px; margin: 0 auto; padding: clamp(22px, 4vw, 56px) clamp(16px, 4vw, 56px) 0; }
374
+ .public.dated { padding-right: clamp(26px, 4vw, 56px); }
375
+ .public-head {
376
+ display: grid;
377
+ grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
378
+ gap: 16px 48px;
379
+ align-items: end;
380
+ margin-bottom: 36px;
381
+ padding-bottom: 28px;
382
+ border-bottom: 1px solid var(--stor-border);
383
+ }
384
+ h1 {
385
+ margin: 0;
386
+ font: 500 clamp(44px, 6.4vw, 96px) / 0.96 var(--stor-font-serif);
387
+ letter-spacing: -0.035em;
388
+ text-wrap: balance;
389
+ }
390
+ .public-dek { max-width: 40ch; margin: 0; color: var(--stor-muted-text); font: 400 19px/1.5 var(--stor-font-serif); }
391
+ .public-meta { margin: 14px 0 0; color: var(--stor-muted-text); font-size: 11px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; }
392
+ .public-head.compact { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
393
+ .public-head.compact .public-meta { margin: 0; }
394
+ .public-title-link { color: inherit; font: 500 28px/1.1 var(--stor-font-serif); letter-spacing: -0.02em; text-decoration: none; }
395
+ .public-head.compact .public-more-link { grid-column: 1 / -1; justify-self: start; }
396
+ .public-cover {
397
+ display: block;
398
+ width: min(100%, calc(min(78vh, 860px) * var(--r)));
399
+ aspect-ratio: var(--r);
400
+ margin: 0 auto 48px;
401
+ overflow: hidden;
402
+ border-radius: 8px;
403
+ background: var(--stor-surface-muted);
404
+ }
405
+ .public-cover img { display: block; width: 100%; height: 100%; object-fit: cover; }
406
+ .public-cover:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 3px; }
407
+ .public-body { display: grid; gap: 28px; }
408
+ .public-group { display: grid; gap: 4px; --gallery-header: 60px; }
409
+ .public-pages { display: flex; min-height: 44px; justify-content: center; }
410
+ .public-more-link {
411
+ display: inline-flex;
412
+ min-height: 44px;
413
+ align-items: center;
414
+ border-radius: 999px;
415
+ padding: 0 18px;
416
+ background: var(--stor-surface-raised);
417
+ box-shadow: inset 0 0 0 1px var(--stor-border);
418
+ color: var(--stor-ink);
419
+ font-size: 13px;
420
+ font-weight: 600;
421
+ text-decoration: none;
422
+ }
423
+ .public-more-link:focus-visible, .public-title-link:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 2px; }
424
+ .public-foot {
425
+ display: flex;
426
+ justify-content: space-between;
427
+ gap: 16px;
428
+ margin-top: 48px;
429
+ padding: 18px 0 40px;
430
+ border-top: 1px solid var(--stor-border);
431
+ color: var(--stor-muted-text);
432
+ font-size: 12px;
433
+ }
434
+ .public-spine { position: fixed; top: 0; right: 0; bottom: 0; z-index: 3; width: 22px; }
435
+ .public-viewer { position: fixed; inset: 0; z-index: 10; }
436
+ @media (min-width: 900px) {
437
+ .public.dated { padding-right: 104px; }
438
+ .public-spine { width: 72px; }
439
+ }
440
+ @media (max-width: 700px) {
441
+ .public-head { grid-template-columns: 1fr; gap: 12px; margin-bottom: 24px; padding-bottom: 22px; }
442
+ h1 { font-size: 46px; }
443
+ .public-dek { font-size: 17px; }
444
+ .public-cover { margin-bottom: 32px; }
445
+ .public-body { gap: 20px; }
446
+ .public-group { --gallery-header: 52px; }
447
+ }
448
+ </style>
@@ -0,0 +1,5 @@
1
+ declare module '*.svelte' {
2
+ import type { Component } from 'svelte';
3
+ const component: Component<any>;
4
+ export default component;
5
+ }
@@ -0,0 +1,7 @@
1
+ import { hydrate } from 'svelte';
2
+ import App from './App.svelte';
3
+ import type { PublicGalleryData } from './types.js';
4
+
5
+ const data = JSON.parse(document.getElementById('gallery-data')?.textContent ?? 'null') as PublicGalleryData | null;
6
+ const target = document.getElementById('app');
7
+ if (data && target) hydrate(App, { target, props: { data } });
@@ -0,0 +1,53 @@
1
+ import type { PublicGalleryMonth, PublicGalleryPhoto } from '../contract.js';
2
+
3
+ /** Photos per page shard in the public bucket. */
4
+ export const PAGE_SIZE = 100;
5
+
6
+ /** A published photo and its place in the reviewed order. */
7
+ export interface PublicPhoto extends PublicGalleryPhoto {
8
+ position: number;
9
+ }
10
+
11
+ /** What the Worker renders and the browser picks up from. */
12
+ export interface PublicGalleryData {
13
+ publicationId: string;
14
+ revision: string;
15
+ title: string;
16
+ description: string;
17
+ photoCount: number;
18
+ months: PublicGalleryMonth[] | null;
19
+ /** The first page shard loaded; later ones follow without gaps. */
20
+ firstPage: number;
21
+ photos: PublicPhoto[];
22
+ /** The photo the address opens. */
23
+ open: number | null;
24
+ }
25
+
26
+ export interface PublicPageResponse {
27
+ revision: string;
28
+ page: number;
29
+ photos: PublicPhoto[];
30
+ }
31
+
32
+ export function galleryPath(publicationId: string, page = 0): string {
33
+ return page > 0 ? `/g/${publicationId}?page=${page}` : `/g/${publicationId}`;
34
+ }
35
+
36
+ /** A photo's address names its asset alone, so it outlives republishing. */
37
+ export function photoPath(publicationId: string, photo: Pick<PublicPhoto, 'assetId'>): string {
38
+ return `/g/${publicationId}/photos/${photo.assetId}`;
39
+ }
40
+
41
+ export function assetPath(publicationId: string, revision: string, photo: Pick<PublicPhoto, 'position' | 'assetId'>, variant: 'thumbnail' | 'display'): string {
42
+ return `/g/${publicationId}/assets/${revision}/${photo.position}/${photo.assetId}/${variant}.webp`;
43
+ }
44
+
45
+ export function pagePath(publicationId: string, page: number): string {
46
+ return `/g/${publicationId}/pages/${page}.json`;
47
+ }
48
+
49
+ /** The asset a photo address names, or null. */
50
+ export function openedAsset(pathname: string): string | null {
51
+ const match = pathname.match(/^\/g\/[0-9a-f-]{36}\/photos\/([0-9a-f-]{36})\/?$/i);
52
+ return match ? match[1].toLowerCase() : null;
53
+ }
@@ -0,0 +1,17 @@
1
+ /** A file of the browser bundle, served by the Worker from memory. */
2
+ export interface ClientAsset {
3
+ path: string;
4
+ body: string;
5
+ type: string;
6
+ }
7
+
8
+ export interface ClientAssets {
9
+ script: ClientAsset | null;
10
+ styles: ClientAsset[];
11
+ }
12
+
13
+ /**
14
+ * The browser bundle. The Worker build replaces this module with the built
15
+ * files; without them (in unit tests) pages render without scripts.
16
+ */
17
+ export const clientAssets: ClientAssets = { script: null, styles: [] };