@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,30 @@
1
+ import { BucketStore } from '@hearthlog/sdk/endless';
2
+ import { queryDatabase } from '@hearthlog/sdk/data';
3
+ import type { GalleryAlbumChip, GalleryFilter } from '../../data/gallery.js';
4
+ import type { GalleryPhoto } from '@hearthlog/sdk/data';
5
+ import { galleryBlockMonth, galleryBlockPart } from '../../gallery/timeline.js';
6
+
7
+ export interface GalleryBlockData {
8
+ /** SQL-ordered photos, newest first. */
9
+ photos: GalleryPhoto[];
10
+ /** Albums named in the month header, which only a month's first block has. */
11
+ albums: GalleryAlbumChip[];
12
+ }
13
+
14
+ /**
15
+ * Loaded blocks of the gallery timeline. Each batch of blocks that scroll
16
+ * into view is one SQL session under the timeline's filter.
17
+ */
18
+ export class GalleryBlocks extends BucketStore<GalleryBlockData> {
19
+ constructor(filter: GalleryFilter, chips: boolean) {
20
+ super(async (keys) => {
21
+ const blocks = await queryDatabase((repositories) => Promise.all(keys.map(async (key) => ({
22
+ photos: await repositories.gallery.blockPhotos(key, filter),
23
+ albums: chips && galleryBlockPart(key) === 0
24
+ ? await repositories.gallery.monthAlbums(galleryBlockMonth(key), filter)
25
+ : [],
26
+ }))));
27
+ return new Map(keys.map((key, index) => [key, blocks[index]]));
28
+ }, '[Gallery] Could not load photos');
29
+ }
30
+ }
@@ -0,0 +1,239 @@
1
+ <script lang="ts">
2
+ import { format } from 'date-fns';
3
+ import type { GalleryMonthSummary } from '../../../data/gallery.js';
4
+ import { formatNumber } from '@hearthlog/sdk/format';
5
+ import { listAxis, monthOffset, nearestKey, shiftMonthKey } from '@hearthlog/sdk/axis';
6
+
7
+ let {
8
+ months,
9
+ first,
10
+ last,
11
+ active = false,
12
+ onJump,
13
+ onLatest,
14
+ }: {
15
+ /** Months that hold photos, newest first. */
16
+ months: GalleryMonthSummary[];
17
+ /** The months on screen. */
18
+ first: string;
19
+ last: string;
20
+ /** Phones show the spine while the list scrolls. */
21
+ active?: boolean;
22
+ onJump: (month: string) => void;
23
+ onLatest: () => void;
24
+ } = $props();
25
+
26
+ let root = $state<HTMLDivElement>();
27
+ let canvas = $state<HTMLCanvasElement>();
28
+ let width = $state(0);
29
+ let height = $state(0);
30
+ let hover = $state<string | null>(null);
31
+ let dragging = $state(false);
32
+ let themeRevision = $state(0);
33
+
34
+ const axis = $derived(listAxis(months.map((month) => month.month), { descending: true }));
35
+ const photosByMonth = $derived(new Map(months.map((month) => [month.month, month.photos])));
36
+ const newest = $derived(months[0]?.month ?? '');
37
+ const total = $derived(newest ? monthOffset(months[months.length - 1].month, newest) + 1 : 0);
38
+ const wide = $derived(width >= 48);
39
+ const padding = 18;
40
+ /** A month's share of the rail; a short library keeps a compact scale in the middle. */
41
+ const step = $derived(total > 0 ? Math.min(24, Math.max(0.5, (height - padding * 2) / total)) : 0);
42
+ const top = $derived(Math.max(padding, (height - step * total) / 2));
43
+ const shown = $derived(active || dragging || hover !== null);
44
+
45
+ /** The rail reads newest first, so the newest month sits at the top. */
46
+ const monthY = (month: string) => top + (monthOffset(newest, month) + 0.5) * step;
47
+ const monthAt = (y: number) => shiftMonthKey(newest, -Math.max(0, Math.min(total - 1, Math.floor((y - top) / step))));
48
+ const label = (month: string) => format(new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1), 'MMMM yyyy');
49
+
50
+ // Colours follow the theme, which can change while the page is open.
51
+ $effect(() => {
52
+ const observer = new MutationObserver(() => themeRevision++);
53
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
54
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
55
+ const changed = () => themeRevision++;
56
+ media.addEventListener('change', changed);
57
+ return () => {
58
+ observer.disconnect();
59
+ media.removeEventListener('change', changed);
60
+ };
61
+ });
62
+
63
+ $effect(() => {
64
+ void themeRevision;
65
+ if (!canvas || !width || !height || !newest) return;
66
+ const ratio = window.devicePixelRatio || 1;
67
+ canvas.width = Math.round(width * ratio);
68
+ canvas.height = Math.round(height * ratio);
69
+ const context = canvas.getContext('2d');
70
+ if (!context) return;
71
+ const style = getComputedStyle(canvas);
72
+ const color = (name: string) => style.getPropertyValue(name).trim();
73
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
74
+ context.clearRect(0, 0, width, height);
75
+
76
+ const right = wide ? 10 : 4;
77
+ if (first && last) {
78
+ const bandTop = monthY(first) - step / 2 - 4;
79
+ const bandBottom = monthY(last) + step / 2 + 4;
80
+ context.fillStyle = color('--stor-accent-soft');
81
+ context.beginPath();
82
+ if (wide) context.roundRect(4, bandTop, width - 8, Math.max(10, bandBottom - bandTop), 7);
83
+ else context.roundRect(width - 11, bandTop, 8, Math.max(14, bandBottom - bandTop), 4);
84
+ context.fill();
85
+ }
86
+
87
+ const peak = months[0]?.peak || 1;
88
+ const thickness = Math.max(1, Math.min(4, step * 0.6));
89
+ for (let offset = 0; offset < total; offset++) {
90
+ const month = shiftMonthKey(newest, -offset);
91
+ const y = top + (offset + 0.5) * step;
92
+ const photos = photosByMonth.get(month);
93
+ if (!photos) {
94
+ context.globalAlpha = 1;
95
+ context.fillStyle = color('--stor-border');
96
+ context.fillRect(width - right - 2, y - 0.5, 2, 1);
97
+ continue;
98
+ }
99
+ const inView = month <= first && month >= last;
100
+ const length = (wide ? 4 : 3) + (wide ? 22 : 8) * Math.sqrt(photos / peak);
101
+ context.globalAlpha = inView ? 1 : 0.55;
102
+ context.fillStyle = inView ? color('--stor-accent-strong') : color('--stor-muted-text');
103
+ context.fillRect(width - right - length, y - thickness / 2, length, thickness);
104
+ }
105
+ context.globalAlpha = 1;
106
+
107
+ if (wide) {
108
+ context.font = `600 10.5px ${color('--stor-font-sans') || 'system-ui'}`;
109
+ context.textBaseline = 'middle';
110
+ context.fillStyle = color('--stor-muted-text');
111
+ let previous = -Infinity;
112
+ for (let offset = 0; offset < total; offset++) {
113
+ const month = shiftMonthKey(newest, -offset);
114
+ if (offset > 0 && !month.endsWith('-01')) continue;
115
+ const y = top + (offset + 0.5) * step;
116
+ if (y - previous < 18) continue;
117
+ context.fillText(month.slice(0, 4), 8, y);
118
+ previous = y;
119
+ }
120
+ }
121
+
122
+ if (hover) {
123
+ context.globalAlpha = 0.55;
124
+ context.fillStyle = color('--stor-ink');
125
+ context.fillRect(wide ? 4 : 0, monthY(hover) - 0.5, width - (wide ? 8 : 0), 1);
126
+ context.globalAlpha = 1;
127
+ }
128
+ });
129
+
130
+ let jumped = '';
131
+ function point(event: PointerEvent): string {
132
+ const month = monthAt(event.clientY - root!.getBoundingClientRect().top);
133
+ hover = month;
134
+ return month;
135
+ }
136
+
137
+ function jump(month: string) {
138
+ const target = nearestKey(axis, month);
139
+ if (target === jumped) return;
140
+ jumped = target;
141
+ onJump(target);
142
+ }
143
+
144
+ function keydown(event: KeyboardEvent) {
145
+ const index = axis.indexOf(first);
146
+ let target: string | null = null;
147
+ if (event.key === 'ArrowUp') target = axis.keyAt(Math.max(0, index - 1));
148
+ else if (event.key === 'ArrowDown') target = axis.keyAt(Math.min(axis.count - 1, index + 1));
149
+ else if (event.key === 'PageUp') target = nearestKey(axis, shiftMonthKey(first, 12));
150
+ else if (event.key === 'PageDown') target = nearestKey(axis, shiftMonthKey(first, -12));
151
+ else if (event.key === 'Home') {
152
+ event.preventDefault();
153
+ onLatest();
154
+ return;
155
+ } else if (event.key === 'End') target = axis.keyAt(axis.count - 1);
156
+ if (!target) return;
157
+ event.preventDefault();
158
+ onJump(target);
159
+ }
160
+ </script>
161
+
162
+ {#if newest}
163
+ <div
164
+ bind:this={root}
165
+ bind:clientWidth={width}
166
+ bind:clientHeight={height}
167
+ class="gallery-spine"
168
+ class:shown
169
+ class:wide
170
+ role="slider"
171
+ tabindex="0"
172
+ aria-label="Months"
173
+ aria-orientation="vertical"
174
+ aria-valuemin={0}
175
+ aria-valuemax={Math.max(0, axis.count - 1)}
176
+ aria-valuenow={axis.indexOf(first)}
177
+ aria-valuetext={first ? label(first) : undefined}
178
+ onpointermove={(event) => { const month = point(event); if (dragging) jump(month); }}
179
+ onpointerdown={(event) => { dragging = true; jumped = ''; root?.setPointerCapture(event.pointerId); jump(point(event)); }}
180
+ onpointerup={() => { dragging = false; }}
181
+ onpointercancel={() => { dragging = false; hover = null; }}
182
+ onpointerleave={() => { if (!dragging) hover = null; }}
183
+ onkeydown={keydown}
184
+ >
185
+ <canvas bind:this={canvas} aria-hidden="true"></canvas>
186
+ {#if hover}
187
+ <span class="gallery-spine-tip" style:top={`${monthY(hover)}px`} aria-hidden="true">
188
+ {label(hover)}<small>{photosByMonth.has(hover) ? `${formatNumber(photosByMonth.get(hover) ?? 0)} ${photosByMonth.get(hover) === 1 ? 'photo' : 'photos'}` : 'No photos'}</small>
189
+ </span>
190
+ {/if}
191
+ </div>
192
+ {/if}
193
+
194
+ <style>
195
+ .gallery-spine {
196
+ position: absolute;
197
+ top: 0;
198
+ right: 0;
199
+ bottom: 0;
200
+ z-index: 3;
201
+ width: 22px;
202
+ cursor: ns-resize;
203
+ touch-action: none;
204
+ opacity: 0;
205
+ pointer-events: none;
206
+ transition: opacity 300ms ease;
207
+ }
208
+ .gallery-spine.shown, .gallery-spine:focus-visible { opacity: 1; pointer-events: auto; }
209
+ /* CSS sets the width; a wide spine is always shown. */
210
+ .gallery-spine.wide { opacity: 1; pointer-events: auto; }
211
+ .gallery-spine:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: -4px; border-radius: 12px; }
212
+ canvas { display: block; width: 100%; height: 100%; }
213
+ .gallery-spine-tip {
214
+ position: absolute;
215
+ right: calc(100% + 6px);
216
+ z-index: 4;
217
+ display: inline-flex;
218
+ align-items: baseline;
219
+ gap: 6px;
220
+ padding: 7px 11px;
221
+ border-radius: 999px;
222
+ background: var(--stor-ink);
223
+ box-shadow: var(--stor-shadow-md);
224
+ color: var(--stor-surface);
225
+ font-size: 12px;
226
+ font-weight: 600;
227
+ line-height: 1;
228
+ white-space: nowrap;
229
+ pointer-events: none;
230
+ transform: translateY(-50%);
231
+ }
232
+ .gallery-spine-tip small { font-size: 11.5px; font-weight: 500; opacity: 0.72; }
233
+ @media (min-width: 640px) {
234
+ .gallery-spine { width: 72px; }
235
+ }
236
+ @media (prefers-reduced-motion: reduce) {
237
+ .gallery-spine { transition: none; }
238
+ }
239
+ </style>
@@ -0,0 +1,157 @@
1
+ <script lang="ts">
2
+ import { onMount, type Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import IconCheck from '~icons/mdi/check';
5
+
6
+ let {
7
+ src,
8
+ alt,
9
+ label,
10
+ size,
11
+ href,
12
+ day = '',
13
+ checkable = false,
14
+ selectable = false,
15
+ selected = false,
16
+ element = $bindable(),
17
+ onOpen,
18
+ onSelect,
19
+ overlay,
20
+ ...attributes
21
+ }: Omit<HTMLAttributes<HTMLElement>, 'children'> & {
22
+ /** The image to show; empty while it loads. */
23
+ src: string;
24
+ alt: string;
25
+ /** What opening the tile does, for assistive technology. */
26
+ label: string;
27
+ /** The tile's size in its row; `null` fills the cell CSS gives it. */
28
+ size: { width: number; height: number } | null;
29
+ /** A real link, so the tile opens without a script. */
30
+ href?: string;
31
+ /** Shown on hover. */
32
+ day?: string;
33
+ /** A check in the corner selects the photo. */
34
+ checkable?: boolean;
35
+ selectable?: boolean;
36
+ selected?: boolean;
37
+ element?: HTMLElement;
38
+ onOpen?: (event: MouseEvent) => void;
39
+ onSelect?: () => void;
40
+ /** Markers over the photo, such as upload state. */
41
+ overlay?: Snippet;
42
+ } = $props();
43
+
44
+ let shown = $state('');
45
+ // Server-rendered photos show at once; once scripts run, a photo still loading fades in.
46
+ let mounted = $state(false);
47
+ onMount(() => { mounted = true; });
48
+
49
+ /**
50
+ * Marks the photo shown once it loads, including one that loaded before the
51
+ * page came alive. A listener rather than an `onload` attribute keeps
52
+ * server-rendered markup free of inline handlers, which the public CSP blocks.
53
+ */
54
+ function trackLoad(node: HTMLImageElement) {
55
+ const loaded = () => { shown = node.getAttribute('src') ?? ''; };
56
+ if (node.complete && node.naturalWidth > 0) loaded();
57
+ node.addEventListener('load', loaded);
58
+ return () => node.removeEventListener('load', loaded);
59
+ }
60
+
61
+ function activate(event: MouseEvent) {
62
+ if (checkable && !selectable && (event.target as Element).closest('[data-gallery-check]')) {
63
+ event.preventDefault();
64
+ onSelect?.();
65
+ } else onOpen?.(event);
66
+ }
67
+ </script>
68
+
69
+ <svelte:element
70
+ this={href ? 'a' : 'button'}
71
+ bind:this={element}
72
+ {...attributes}
73
+ type={href ? undefined : 'button'}
74
+ {href}
75
+ class="gallery-tile"
76
+ class:fill={!size}
77
+ class:selectable
78
+ class:selected
79
+ style:width={size ? `${size.width}px` : undefined}
80
+ style:height={size ? `${size.height}px` : undefined}
81
+ aria-label={label}
82
+ aria-pressed={selectable ? selected : undefined}
83
+ onclick={activate}
84
+ >
85
+ {#if src}<img {src} {alt} class:waiting={mounted && shown !== src} draggable="false" {@attach trackLoad} />{/if}
86
+ {#if checkable}<span class="gallery-check" data-gallery-check aria-hidden="true"><IconCheck /></span>{/if}
87
+ {#if day}<span class="gallery-day" aria-hidden="true">{day}</span>{/if}
88
+ {@render overlay?.()}
89
+ </svelte:element>
90
+
91
+ <style>
92
+ .gallery-tile {
93
+ position: relative;
94
+ flex: none;
95
+ display: block;
96
+ overflow: hidden;
97
+ border: 0;
98
+ border-radius: 8px;
99
+ padding: 0;
100
+ background: var(--stor-surface-muted);
101
+ color: inherit;
102
+ text-align: left;
103
+ transition: background-color 180ms ease;
104
+ }
105
+ .gallery-tile.fill { width: 100%; height: 100%; }
106
+ .gallery-tile:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 2px; }
107
+ img {
108
+ position: absolute;
109
+ inset: 0;
110
+ width: 100%;
111
+ height: 100%;
112
+ object-fit: cover;
113
+ transition: opacity 400ms ease, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-radius 220ms ease;
114
+ }
115
+ img.waiting { opacity: 0; }
116
+ .selected { background: var(--stor-accent-soft); }
117
+ .selected img { transform: scale(0.86); border-radius: 6px; }
118
+ .gallery-check {
119
+ position: absolute;
120
+ top: 7px;
121
+ left: 7px;
122
+ z-index: 1;
123
+ display: grid;
124
+ width: 22px;
125
+ height: 22px;
126
+ place-items: center;
127
+ border-radius: 999px;
128
+ background: rgb(0 0 0 / 0.18);
129
+ box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.95), 0 1px 4px rgb(0 0 0 / 0.25);
130
+ color: transparent;
131
+ opacity: 0;
132
+ transition: opacity 150ms ease, background-color 150ms ease;
133
+ }
134
+ .gallery-check :global(svg) { width: 14px; height: 14px; }
135
+ .selectable .gallery-check, .gallery-tile:focus-visible .gallery-check { opacity: 1; }
136
+ .selected .gallery-check { background: var(--stor-accent); box-shadow: 0 0 0 2px var(--stor-surface); color: var(--stor-action-text, #fff); }
137
+ .gallery-day {
138
+ position: absolute;
139
+ inset: auto 0 0;
140
+ padding: 18px 9px 7px;
141
+ background: linear-gradient(transparent, rgb(0 0 0 / 0.42));
142
+ color: #fff;
143
+ font-size: 11px;
144
+ font-weight: 600;
145
+ line-height: 1;
146
+ opacity: 0;
147
+ pointer-events: none;
148
+ transition: opacity 180ms ease;
149
+ }
150
+ @media (hover: hover) {
151
+ .gallery-tile:hover .gallery-check, .gallery-tile:hover .gallery-day { opacity: 1; }
152
+ .gallery-tile:not(.selected):hover img { transform: scale(1.015); }
153
+ }
154
+ @media (prefers-reduced-motion: reduce) {
155
+ img, .gallery-check, .gallery-day { transition: none; }
156
+ }
157
+ </style>
@@ -0,0 +1,54 @@
1
+ <script lang="ts" generics="T extends { width: number; height: number }">
2
+ import type { Snippet } from 'svelte';
3
+ import { justifiedRows, tileRatio } from '../../../gallery/layout.js';
4
+
5
+ let {
6
+ items,
7
+ key,
8
+ width = 0,
9
+ rowHeight,
10
+ gap,
11
+ tile,
12
+ }: {
13
+ items: readonly T[];
14
+ key: (item: T) => string;
15
+ /**
16
+ * The width rows fill. Until it is known (on the server, or before the
17
+ * first measure) rows flow in CSS, each photo growing by its ratio.
18
+ */
19
+ width?: number;
20
+ rowHeight: number;
21
+ gap: number;
22
+ /** Draws one photo at its size in the row; `null` while CSS sizes it. */
23
+ tile: Snippet<[item: T, size: { width: number; height: number } | null, index: number]>;
24
+ } = $props();
25
+
26
+ const rows = $derived(width > 0 ? justifiedRows(items.map(tileRatio), width, rowHeight, gap) : null);
27
+ </script>
28
+
29
+ {#if rows}
30
+ <div class="rows" style:--gap={`${gap}px`}>
31
+ {#each rows as row (row.start)}
32
+ <div class="row" style:height={`${row.height}px`}>
33
+ {#each items.slice(row.start, row.end) as item, offset (key(item))}
34
+ {@render tile(item, { width: tileRatio(item) * row.height, height: row.height }, row.start + offset)}
35
+ {/each}
36
+ </div>
37
+ {/each}
38
+ </div>
39
+ {:else}
40
+ <div class="flow" style:--gap={`${gap}px`} style:--row={`${rowHeight}px`}>
41
+ {#each items as item, index (key(item))}
42
+ <div class="cell" style:--r={tileRatio(item)}>{@render tile(item, null, index)}</div>
43
+ {/each}
44
+ </div>
45
+ {/if}
46
+
47
+ <style>
48
+ .rows { display: flex; flex-direction: column; gap: var(--gap); }
49
+ .row { display: flex; gap: var(--gap); }
50
+ .flow { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--gap); }
51
+ /* The last row keeps the target height instead of stretching. */
52
+ .flow::after { content: ''; flex: 1000 1 0%; }
53
+ .cell { display: flex; min-width: 0; flex: var(--r) 1 calc(var(--r) * var(--row)); aspect-ratio: var(--r); }
54
+ </style>
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { formatMonthParts, formatPhotoCount } from '../../../gallery/format.js';
4
+
5
+ let { month, count, before, after }: {
6
+ /** A `yyyy-MM` month. */
7
+ month: string;
8
+ count: number;
9
+ /** Controls ahead of the name, such as a select-all check. */
10
+ before?: Snippet;
11
+ /** Controls after the name, such as album chips. */
12
+ after?: Snippet;
13
+ } = $props();
14
+
15
+ const parts = $derived(formatMonthParts(month));
16
+ </script>
17
+
18
+ <header class="gallery-month-header" data-gallery-month={month}>
19
+ {@render before?.()}
20
+ <h2 class="gallery-month-title">{parts.name} <span>{parts.year}</span></h2>
21
+ {@render after?.()}
22
+ <span class="gallery-month-count">{formatPhotoCount(count)}</span>
23
+ </header>
24
+
25
+ <style>
26
+ .gallery-month-header {
27
+ position: sticky;
28
+ top: 0;
29
+ z-index: 2;
30
+ display: flex;
31
+ min-height: var(--gallery-header, 52px);
32
+ align-items: center;
33
+ gap: 10px;
34
+ margin-inline: calc(var(--gallery-inset-start, 0px) * -1) calc(var(--gallery-inset-end, 0px) * -1);
35
+ padding-inline: var(--gallery-inset-start, 0px) var(--gallery-inset-end, 0px);
36
+ background: var(--stor-surface);
37
+ }
38
+ .gallery-month-title {
39
+ margin: 0;
40
+ color: var(--stor-ink);
41
+ font-family: var(--stor-font-serif, ui-serif, Georgia, serif);
42
+ font-size: 20px;
43
+ font-weight: 600;
44
+ letter-spacing: -0.012em;
45
+ line-height: 1.1;
46
+ white-space: nowrap;
47
+ }
48
+ .gallery-month-title span { font-weight: 400; color: var(--stor-muted-text); }
49
+ .gallery-month-count { margin-left: auto; color: var(--stor-muted-text); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; }
50
+ @media (min-width: 640px) {
51
+ .gallery-month-title { font-size: 23px; }
52
+ }
53
+ </style>