@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,219 @@
1
+ <script lang="ts">
2
+ import { Dialog, DropdownMenu } from 'bits-ui';
3
+ import type { GalleryPhoto, GalleryAlbum } from '@hearthlog/sdk/data';
4
+ import type { GalleryFilter } from '../../data/gallery.js';
5
+ import { queryDatabase } from '@hearthlog/sdk/data';
6
+ import { galleryOriginal, galleryRendition, galleryUploadState, type GalleryFileScope } from '../../gallery/file-store.js';
7
+ import { tileRatio } from '../../gallery/layout.js';
8
+ import { formatPhotoDate, photoDetailRows } from '../../gallery/format.js';
9
+ import { runCommandAction } from '@hearthlog/sdk/runtime';
10
+ import { CommandOnly } from "@hearthlog/sdk/ui";
11
+ import PhotoThumb from './PhotoThumb.svelte';
12
+ import ViewerFrame from './view/ViewerFrame.svelte';
13
+ import IconDownload from '~icons/mdi/download';
14
+ import IconHeart from '~icons/mdi/heart-outline';
15
+ import IconHeartFilled from '~icons/mdi/heart';
16
+ import IconPencil from '~icons/mdi/pencil-outline';
17
+ import IconDelete from '~icons/mdi/delete-outline';
18
+ import IconDotsVertical from '~icons/mdi/dots-vertical';
19
+
20
+ let {
21
+ photo, tenantId, logId, filter, revision, onClose, onNavigate, onFavorite, onEdit, onRemove, onOpenAlbum,
22
+ }: {
23
+ photo: GalleryPhoto;
24
+ tenantId: string;
25
+ logId: string;
26
+ /** The timeline the photo was opened from; the filmstrip and arrows stay within it. */
27
+ filter: GalleryFilter;
28
+ revision: number;
29
+ onClose: () => void;
30
+ onNavigate: (id: string) => void;
31
+ onFavorite: () => void;
32
+ onEdit: () => void;
33
+ onRemove: () => void;
34
+ onOpenAlbum: (id: string) => void;
35
+ } = $props();
36
+
37
+ let imageUrl = $state('');
38
+ let backdropUrl = $state('');
39
+ let error = $state('');
40
+ let uploadStatus = $state<'pending' | 'needs_attention' | 'remote'>('remote');
41
+ // Details sit beside the photo on desktop and open as a sheet on phones.
42
+ let detailsOpen = $state(typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches);
43
+ /** The photo with its neighbours, oldest first. The last strip stays until the next one is read. */
44
+ let film = $state.raw<GalleryPhoto[]>([]);
45
+ let albums = $state<GalleryAlbum[]>([]);
46
+
47
+ const scope = $derived<GalleryFileScope>({ tenantId, logId, photoId: photo.id, fileVersionId: photo.file_version_id });
48
+ const place = $derived(film.findIndex((item) => item.id === photo.id));
49
+ const older = $derived(place > 0 ? film[place - 1] : null);
50
+ const newer = $derived(place >= 0 && place < film.length - 1 ? film[place + 1] : null);
51
+ const details = $derived(photoDetailRows(photo));
52
+ const filmItems = $derived(film.map((item) => ({ id: item.id, ratio: tileRatio(item), label: item.filename })));
53
+
54
+ $effect(() => {
55
+ if (!scope.tenantId || !scope.logId) return;
56
+ let cancelled = false;
57
+ const urls: string[] = [];
58
+ const show = (blob: Blob) => {
59
+ const url = URL.createObjectURL(blob);
60
+ urls.push(url);
61
+ return url;
62
+ };
63
+ error = '';
64
+ void galleryRendition(scope, 'thumbnail').then((blob) => {
65
+ if (cancelled) return;
66
+ backdropUrl = show(blob);
67
+ if (!imageUrl) imageUrl = backdropUrl;
68
+ }).catch(() => undefined);
69
+ void galleryRendition(scope, 'display').then((blob) => {
70
+ if (!cancelled) imageUrl = show(blob);
71
+ }).catch((cause) => {
72
+ if (!cancelled && !imageUrl) error = cause instanceof Error ? cause.message : 'Photo unavailable';
73
+ });
74
+ return () => {
75
+ cancelled = true;
76
+ imageUrl = '';
77
+ backdropUrl = '';
78
+ // Keep the old images a moment so the next photo fades in over them.
79
+ setTimeout(() => urls.forEach((url) => URL.revokeObjectURL(url)), 1000);
80
+ };
81
+ });
82
+
83
+ $effect(() => {
84
+ if (!scope.tenantId || !scope.logId) return;
85
+ let cancelled = false;
86
+ const update = () => {
87
+ void galleryUploadState(scope).then((upload) => {
88
+ if (!cancelled) uploadStatus = upload?.state ?? 'remote';
89
+ }).catch(() => { if (!cancelled) uploadStatus = 'remote'; });
90
+ };
91
+ update();
92
+ const transferred = (event: Event) => {
93
+ if ((event as CustomEvent<string>).detail === photo.id) update();
94
+ };
95
+ window.addEventListener('stor:gallery-transfer', transferred);
96
+ return () => { cancelled = true; window.removeEventListener('stor:gallery-transfer', transferred); };
97
+ });
98
+
99
+ // Neighbours for the arrows and the filmstrip, and the albums holding the photo, in SQL.
100
+ $effect(() => {
101
+ const current = photo;
102
+ const within = filter;
103
+ void revision;
104
+ let cancelled = false;
105
+ void queryDatabase(async (repositories) => ({
106
+ nearby: await repositories.gallery.strip(current, within, 8),
107
+ albums: await repositories.gallery.photoAlbums(current.id),
108
+ })).then((result) => {
109
+ if (cancelled) return;
110
+ film = [...result.nearby.older, current, ...result.nearby.newer];
111
+ albums = result.albums;
112
+ }).catch((cause) => console.warn('[Gallery] Could not read nearby photos', cause));
113
+ return () => { cancelled = true; };
114
+ });
115
+
116
+ /** F favorites the photo; the frame handles the arrows and details. */
117
+ function handleKey(event: KeyboardEvent) {
118
+ if (event.key === 'f') runCommandAction(onFavorite);
119
+ }
120
+
121
+ async function downloadOriginal() {
122
+ try {
123
+ const blob = await galleryOriginal(scope);
124
+ const url = URL.createObjectURL(blob);
125
+ const link = document.createElement('a');
126
+ link.href = url;
127
+ link.download = photo.filename;
128
+ link.click();
129
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
130
+ } catch {
131
+ error = 'The original could not be downloaded.';
132
+ }
133
+ }
134
+ </script>
135
+
136
+ <Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
137
+ <Dialog.Portal>
138
+ <Dialog.Overlay class="fixed inset-0 z-[var(--stor-layer-sheet)] bg-black/60" />
139
+ <Dialog.Content class="fixed inset-0 z-[var(--stor-layer-sheet-content)] overflow-hidden outline-none lg:inset-5 lg:rounded-[var(--stor-panel-radius)]">
140
+ <ViewerFrame
141
+ title={formatPhotoDate(photo)}
142
+ subtitle={`${photo.filename}${photo.captured_at ? '' : ' · Imported'}`}
143
+ status={uploadStatus === 'remote' ? '' : uploadStatus === 'pending' ? 'Saved on this device' : 'Upload needs attention'}
144
+ imageKey={photo.id}
145
+ {imageUrl}
146
+ {backdropUrl}
147
+ alt={photo.caption || photo.filename}
148
+ caption={photo.caption}
149
+ message={error}
150
+ older={older ? { id: older.id } : null}
151
+ newer={newer ? { id: newer.id } : null}
152
+ film={filmItems}
153
+ currentId={photo.id}
154
+ {details}
155
+ bind:detailsOpen
156
+ {onClose}
157
+ {onNavigate}
158
+ onKey={handleKey}
159
+ >
160
+ {#snippet heading(title)}<Dialog.Title class="viewer-title">{title}</Dialog.Title>{/snippet}
161
+ {#snippet actions()}
162
+ <CommandOnly>
163
+ <button type="button" class="viewer-button" class:favorite={photo.favorite} aria-pressed={Boolean(photo.favorite)} aria-label={photo.favorite ? 'Remove favorite' : 'Add favorite'} onclick={onFavorite}>
164
+ {#if photo.favorite}<IconHeartFilled />{:else}<IconHeart />{/if}
165
+ </button>
166
+ <button type="button" class="viewer-button desktop-only" aria-label="Edit photo" onclick={onEdit}><IconPencil /></button>
167
+ <button type="button" class="viewer-button desktop-only" aria-label="Remove photo" onclick={onRemove}><IconDelete /></button>
168
+ </CommandOnly>
169
+ <button type="button" class="viewer-button desktop-only" aria-label="Download original" onclick={() => void downloadOriginal()}><IconDownload /></button>
170
+ <DropdownMenu.Root>
171
+ <DropdownMenu.Trigger class="viewer-button phone-only" aria-label="Photo actions"><IconDotsVertical /></DropdownMenu.Trigger>
172
+ <DropdownMenu.Portal>
173
+ <DropdownMenu.Content side="bottom" align="end" sideOffset={4} class="stor-menu z-[var(--stor-layer-menu)] w-48 py-1">
174
+ <DropdownMenu.Item class="flex cursor-default items-center gap-2 px-3 py-2 text-sm text-[var(--stor-ink)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)]" onSelect={() => void downloadOriginal()}><IconDownload class="h-4 w-4" /> Download original</DropdownMenu.Item>
175
+ <DropdownMenu.Separator class="my-1 h-px bg-[var(--stor-border)]" />
176
+ <CommandOnly>
177
+ <DropdownMenu.Item class="flex cursor-default items-center gap-2 px-3 py-2 text-sm text-[var(--stor-ink)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)]" onSelect={onEdit}><IconPencil class="h-4 w-4" /> Edit photo</DropdownMenu.Item>
178
+ <DropdownMenu.Item class="flex cursor-default items-center gap-2 px-3 py-2 text-sm text-[var(--stor-negative)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)]" onSelect={onRemove}><IconDelete class="h-4 w-4" /> Remove photo</DropdownMenu.Item>
179
+ </CommandOnly>
180
+ </DropdownMenu.Content>
181
+ </DropdownMenu.Portal>
182
+ </DropdownMenu.Root>
183
+ {/snippet}
184
+ {#snippet thumb(item)}
185
+ {@const shown = film.find((photo) => photo.id === item.id)}
186
+ {#if shown}<PhotoThumb photo={shown} {tenantId} {logId} />{/if}
187
+ {/snippet}
188
+ {#snippet detailsExtra()}
189
+ {#if albums.length}
190
+ <dl class="viewer-albums">
191
+ <dt>Albums</dt>
192
+ <dd>
193
+ {#each albums as album (album.id)}
194
+ <button type="button" class="viewer-chip" onclick={() => onOpenAlbum(album.id)}>{album.title}</button>
195
+ {/each}
196
+ </dd>
197
+ </dl>
198
+ {/if}
199
+ {/snippet}
200
+ </ViewerFrame>
201
+ </Dialog.Content>
202
+ </Dialog.Portal>
203
+ </Dialog.Root>
204
+
205
+ <style>
206
+ .favorite { color: #ff8f8a !important; }
207
+ .desktop-only { display: none !important; }
208
+ :global(.viewer-button.phone-only) { display: grid; }
209
+ @media (min-width: 640px) {
210
+ .desktop-only { display: grid !important; }
211
+ :global(.viewer-button.phone-only) { display: none !important; }
212
+ }
213
+ .viewer-albums { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px 14px; margin: 0; }
214
+ .viewer-albums dt { color: rgb(238 242 246 / 0.48); font-size: 10.5px; font-weight: 600; letter-spacing: 0.07em; line-height: 1.7; text-transform: uppercase; }
215
+ .viewer-albums dd { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }
216
+ .viewer-chip { border: 0; border-radius: 999px; background: rgb(255 255 255 / 0.1); padding: 5px 10px; color: #eef2f6; font-size: 11.5px; font-weight: 600; line-height: 1; }
217
+ .viewer-chip:hover { background: rgb(255 255 255 / 0.18); }
218
+ .viewer-chip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
219
+ </style>
@@ -0,0 +1,386 @@
1
+ <script lang="ts">
2
+ import { queryDatabase } from '@hearthlog/sdk/data';
3
+ import type { GalleryPhoto, GalleryPublication } from '@hearthlog/sdk/data';
4
+ import { galleryPublicationCommands } from '@hearthlog/sdk/data';
5
+ import { refreshPageData } from '@hearthlog/sdk/runtime';
6
+ import { syncAndWait } from '@hearthlog/sdk/runtime';
7
+ import { notify } from '@hearthlog/sdk/runtime';
8
+ import {
9
+ cleanupPublicRevision, publicationRevision, publicationStatus, publishGallery, unpublishGallery,
10
+ type PublicationReview, type PublicationReviewPhoto, type PublicationStatus,
11
+ } from '../../gallery/publication-api.js';
12
+ import { Button } from "@hearthlog/sdk/ui";
13
+ import { Input } from "@hearthlog/sdk/ui";
14
+ import { Textarea } from "@hearthlog/sdk/ui";
15
+ import { Checkbox } from "@hearthlog/sdk/ui";
16
+ import JustifiedRows from './view/JustifiedRows.svelte';
17
+ import { tileRatio } from '../../gallery/layout.js';
18
+ import { galleryGeometry } from '../../gallery/timeline.js';
19
+ import {
20
+ detailSwitchesFrom, publicPhotoDetails, SHARE_ALL_DETAILS, type PublicDetailSwitches,
21
+ } from '../../gallery/public-details.js';
22
+ import { CommandDrawer } from "@hearthlog/sdk/ui";
23
+ import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
24
+ import { CommandOnly } from "@hearthlog/sdk/ui";
25
+ import { EmptyState } from "@hearthlog/sdk/ui";
26
+ import PublicationPreviewPhoto from './PublicationPreviewPhoto.svelte';
27
+ import IconImage from '~icons/mdi/image-multiple-outline';
28
+ import IconLink from '~icons/mdi/link-variant';
29
+ import IconArrowUp from '~icons/mdi/arrow-up';
30
+ import IconArrowDown from '~icons/mdi/arrow-down';
31
+ import IconClose from '~icons/mdi/close';
32
+
33
+ export interface PublicationSource {
34
+ albumId?: string;
35
+ photoIds?: string[];
36
+ title: string;
37
+ description: string;
38
+ }
39
+
40
+ interface DraftPhoto extends PublicationReviewPhoto {
41
+ filename: string;
42
+ /** The private photo, whose details the switches publish. */
43
+ source: GalleryPhoto;
44
+ }
45
+ interface PendingPublish { publicationId: string; revision: string; review: PublicationReview }
46
+
47
+ let { tenantId, logId, revision, source, showList, onCloseSource }: {
48
+ tenantId: string;
49
+ logId: string;
50
+ revision: number;
51
+ source: PublicationSource | null;
52
+ showList: boolean;
53
+ onCloseSource: () => void;
54
+ } = $props();
55
+
56
+ let publications = $state<GalleryPublication[]>([]);
57
+ let statuses = $state<Record<string, PublicationStatus>>({});
58
+ let draftOpen = $state(false);
59
+ let draftLoading = $state(false);
60
+ let draftError = $state('');
61
+ let draftTitle = $state('');
62
+ let draftDescription = $state('');
63
+ let draftPhotos = $state<DraftPhoto[]>([]);
64
+ let targetId = $state('');
65
+ let pending = $state<PendingPublish | null>(null);
66
+ let busy = $state(false);
67
+ let progress = $state('');
68
+ let unpublishId = $state('');
69
+ let existingTitle = $state('');
70
+ let previewMode = $state<'desktop' | 'phone'>('desktop');
71
+ let previewWidth = $state(0);
72
+ let shareDetails = $state<PublicDetailSwitches>({ ...SHARE_ALL_DETAILS });
73
+ const previewGeometry = $derived(galleryGeometry(previewWidth, 1));
74
+ let reviewPage = $state(0);
75
+ const reviewPageSize = 60;
76
+ const reviewPages = $derived(Math.ceil(draftPhotos.length / reviewPageSize));
77
+ const visibleReviewPhotos = $derived(draftPhotos.slice(reviewPage * reviewPageSize, (reviewPage + 1) * reviewPageSize));
78
+
79
+ $effect(() => {
80
+ if (!tenantId || !logId) return;
81
+ void revision;
82
+ let cancelled = false;
83
+ void queryDatabase((repositories) => repositories.gallery.publications()).then(async (rows) => {
84
+ if (cancelled) return;
85
+ publications = rows;
86
+ const resolved = await Promise.allSettled(rows.map((row) => publicationStatus(logId, row.id)));
87
+ if (cancelled) return;
88
+ const next: Record<string, PublicationStatus> = {};
89
+ resolved.forEach((result, index) => {
90
+ if (result.status === 'fulfilled') {
91
+ next[rows[index].id] = result.value;
92
+ if (!draftOpen && !busy) void cleanRetired(result.value);
93
+ }
94
+ });
95
+ statuses = next;
96
+ }).catch(() => { if (!cancelled) notify.error('Could not load shared galleries'); });
97
+ return () => { cancelled = true; };
98
+ });
99
+
100
+ $effect(() => {
101
+ const requested = source;
102
+ if (!requested || !tenantId || !logId) return;
103
+ let cancelled = false;
104
+ draftOpen = true;
105
+ draftLoading = true;
106
+ draftError = '';
107
+ draftTitle = requested.title;
108
+ draftDescription = requested.description;
109
+ draftPhotos = [];
110
+ shareDetails = { ...SHARE_ALL_DETAILS };
111
+ previewMode = typeof window !== 'undefined' && window.innerWidth < 640 ? 'phone' : 'desktop';
112
+ reviewPage = 0;
113
+ targetId = '';
114
+ pending = null;
115
+ void queryDatabase((repositories) => requested.albumId
116
+ ? repositories.gallery.publicationAlbumPhotos(requested.albumId)
117
+ : repositories.gallery.publicationSelectedPhotos(requested.photoIds ?? [], 'time')).then((photos) => {
118
+ if (cancelled) return;
119
+ if (photos.length === 0) throw new Error('Choose at least one photo to share.');
120
+ if (photos.length > 10_000) throw new Error('A public gallery can contain up to 10,000 photos.');
121
+ if (requested.photoIds && photos.length !== requested.photoIds.length) throw new Error('Some selected photos are no longer in this log.');
122
+ draftPhotos = photos.map(toDraftPhoto);
123
+ draftLoading = false;
124
+ }).catch((cause) => {
125
+ if (cancelled) return;
126
+ draftLoading = false;
127
+ draftError = cause instanceof Error ? cause.message : 'Photos could not be loaded.';
128
+ });
129
+ return () => { cancelled = true; };
130
+ });
131
+
132
+ function toDraftPhoto(photo: GalleryPhoto): DraftPhoto {
133
+ return {
134
+ photoId: photo.id, fileVersionId: photo.file_version_id,
135
+ filename: photo.filename, caption: '', alt: 'Photo',
136
+ width: Math.max(1, photo.width), height: Math.max(1, photo.height),
137
+ source: photo,
138
+ };
139
+ }
140
+
141
+ function closeDraft() {
142
+ if (busy) return;
143
+ draftOpen = false;
144
+ draftError = '';
145
+ pending = null;
146
+ onCloseSource();
147
+ }
148
+
149
+ function movePhoto(index: number, direction: -1 | 1) {
150
+ if (pending) return;
151
+ const destination = index + direction;
152
+ if (destination < 0 || destination >= draftPhotos.length) return;
153
+ const next = [...draftPhotos];
154
+ [next[index], next[destination]] = [next[destination], next[index]];
155
+ draftPhotos = next;
156
+ }
157
+
158
+ function removePhoto(index: number) {
159
+ if (pending) return;
160
+ draftPhotos = draftPhotos.filter((_, position) => position !== index);
161
+ if (reviewPage > 0 && reviewPage * reviewPageSize >= draftPhotos.length) reviewPage -= 1;
162
+ }
163
+
164
+ async function editPublication(row: GalleryPublication) {
165
+ draftOpen = true;
166
+ draftLoading = true;
167
+ draftError = '';
168
+ pending = null;
169
+ targetId = row.id;
170
+ onCloseSource();
171
+ try {
172
+ const review = await publicationRevision(logId, row.id, row.revision_id);
173
+ const photos = await queryDatabase((repositories) => repositories.gallery.publicationSelectedPhotos(review.photos.map((photo) => photo.photoId)));
174
+ if (photos.length !== review.photos.length) throw new Error('Some shared photos are no longer in this log. Start a new review from an album.');
175
+ draftTitle = review.title;
176
+ draftDescription = review.description;
177
+ shareDetails = detailSwitchesFrom(review.photos);
178
+ draftPhotos = photos.map((photo, index) => ({
179
+ photoId: review.photos[index].photoId, fileVersionId: review.photos[index].fileVersionId,
180
+ caption: review.photos[index].caption, alt: review.photos[index].alt,
181
+ width: review.photos[index].width, height: review.photos[index].height,
182
+ filename: photo.filename, source: photo,
183
+ }));
184
+ reviewPage = 0;
185
+ } catch (cause) { draftError = cause instanceof Error ? cause.message : 'This review could not be loaded.'; }
186
+ finally { draftLoading = false; }
187
+ }
188
+
189
+ async function publish() {
190
+ if (busy || draftLoading) return;
191
+ if (!navigator.onLine) { draftError = 'Connect to the internet before publishing.'; return; }
192
+ if (!draftTitle.trim()) { draftError = 'Enter a public title.'; return; }
193
+ if (draftPhotos.length === 0) { draftError = 'Choose at least one photo.'; return; }
194
+ if (draftPhotos.some((photo) => !photo.alt.trim())) { draftError = 'Add a description for every photo.'; return; }
195
+ busy = true;
196
+ draftError = '';
197
+ try {
198
+ if (!pending) {
199
+ const publicationId = targetId || crypto.randomUUID();
200
+ const nextRevision = crypto.randomUUID();
201
+ const review: PublicationReview = {
202
+ title: draftTitle.trim(), description: draftDescription.trim(),
203
+ photos: draftPhotos.map((photo) => ({
204
+ photoId: photo.photoId, fileVersionId: photo.fileVersionId,
205
+ caption: photo.caption.trim(), alt: photo.alt.trim(), width: photo.width, height: photo.height,
206
+ ...publicPhotoDetails(photo.source, shareDetails),
207
+ })),
208
+ };
209
+ const intent = {
210
+ id: publicationId, ownerLogId: logId, title: review.title,
211
+ description: review.description, revisionId: nextRevision,
212
+ requestedAt: new Date().toISOString(), lastOperation: 'publish',
213
+ };
214
+ if (targetId) await galleryPublicationCommands.update(intent, { submissionId: crypto.randomUUID() });
215
+ else await galleryPublicationCommands.create(intent, { submissionId: crypto.randomUUID() });
216
+ pending = { publicationId, revision: nextRevision, review };
217
+ }
218
+ progress = 'Syncing review…';
219
+ await syncAndWait();
220
+ const attempt = pending;
221
+ if (!attempt) throw new Error('The review changed while publishing. Retry from Shared galleries.');
222
+ const status = await publishGallery(logId, attempt.publicationId, attempt.revision, attempt.review,
223
+ (completed, total) => progress = `Preparing photos ${completed} of ${total}…`);
224
+ statuses = { ...statuses, [attempt.publicationId]: status };
225
+ void cleanRetired(status);
226
+ progress = '';
227
+ pending = null;
228
+ draftOpen = false;
229
+ onCloseSource();
230
+ await refreshPageData('app:gallery');
231
+ notify.success('Public gallery is ready');
232
+ } catch (cause) {
233
+ progress = '';
234
+ draftError = cause instanceof Error ? cause.message : 'The gallery could not be published.';
235
+ } finally { busy = false; }
236
+ }
237
+
238
+ async function unpublish() {
239
+ const id = unpublishId;
240
+ if (!id || busy) return;
241
+ if (!navigator.onLine) { notify.error('Connect to the internet before unpublishing'); return; }
242
+ busy = true;
243
+ try {
244
+ await galleryPublicationCommands.update({
245
+ id, lastOperation: 'unpublish', requestedAt: new Date().toISOString(),
246
+ }, { submissionId: crypto.randomUUID() });
247
+ await syncAndWait();
248
+ const status = await unpublishGallery(logId, id);
249
+ statuses = { ...statuses, [id]: status };
250
+ void cleanRetired(status);
251
+ unpublishId = '';
252
+ await refreshPageData('app:gallery');
253
+ notify.success('Gallery unpublished');
254
+ } catch (cause) { notify.error(cause instanceof Error ? cause.message : 'Gallery could not be unpublished'); }
255
+ finally { busy = false; }
256
+ }
257
+
258
+ async function copyLink(url: string) {
259
+ try { await navigator.clipboard.writeText(url); notify.success('Link copied'); }
260
+ catch { notify.error('Could not copy the link'); }
261
+ }
262
+
263
+ async function cleanRetired(status: PublicationStatus) {
264
+ for (const revisionId of status.cleanupRevisions ?? []) {
265
+ try {
266
+ for (let batch = 0; batch < 100; batch += 1) {
267
+ const result = await cleanupPublicRevision(logId, status.publicationId, revisionId);
268
+ if (!result.remaining) break;
269
+ }
270
+ } catch {
271
+ // The marker remains durable. The Shared tab retries cleanup later.
272
+ }
273
+ }
274
+ }
275
+ </script>
276
+
277
+ {#if showList}
278
+ {#if publications.length === 0}
279
+ <EmptyState icon={IconImage} title="No shared galleries" description="Select photos or open an album to prepare an unlisted public gallery." />
280
+ {:else}
281
+ <div class="stor-panel divide-y divide-[var(--stor-border)] px-3 sm:px-4">
282
+ {#each publications as item (item.id)}
283
+ <div class="flex flex-wrap items-center gap-3 py-4">
284
+ <div class="min-w-0 flex-1">
285
+ <h2 class="truncate font-medium text-[var(--stor-ink)]">{item.title}</h2>
286
+ <p class="text-sm text-[var(--stor-muted-text)]">{statuses[item.id]?.activeRevision ? 'Anyone with link' : statuses[item.id] ? 'Unpublished' : 'Checking status…'}</p>
287
+ </div>
288
+ <CommandOnly><Button variant="secondary" onclick={() => void editPublication(item)}>Review update</Button></CommandOnly>
289
+ {#if statuses[item.id]?.activeRevision}
290
+ <Button variant="ghost" onclick={() => void copyLink(statuses[item.id].publicUrl)}><IconLink class="h-4 w-4" /> Copy link</Button>
291
+ <a class="inline-flex h-11 items-center rounded-xl px-3 text-sm text-[var(--stor-ink)] hover:bg-[var(--stor-surface-muted)] sm:h-9" href={statuses[item.id].publicUrl} target="_blank" rel="noopener noreferrer">View</a>
292
+ <CommandOnly><Button variant="ghost" onclick={() => { unpublishId = item.id; existingTitle = item.title; }}>Unpublish</Button></CommandOnly>
293
+ {/if}
294
+ </div>
295
+ {/each}
296
+ </div>
297
+ {/if}
298
+ {/if}
299
+
300
+ <CommandDrawer show={draftOpen} title={targetId ? 'Review public update' : 'Publish gallery'} description="Anyone with the link can view and save these photos." size="lg" onClose={closeDraft}>
301
+ <div class="space-y-5 text-sm text-[var(--stor-ink)]">
302
+ {#if draftLoading}<p role="status">Loading review…</p>{/if}
303
+ {#if !draftLoading && draftPhotos.length > 0}
304
+ <div class="space-y-3">
305
+ <label class="block font-medium">Public title<Input bind:value={draftTitle} maxlength={120} class="mt-1" disabled={busy || Boolean(pending)} /></label>
306
+ <label class="block font-medium">Description<Textarea bind:value={draftDescription} maxlength={500} class="mt-1" disabled={busy || Boolean(pending)} /></label>
307
+ <fieldset class="space-y-1">
308
+ <legend class="font-medium">Photo details</legend>
309
+ <p class="text-[var(--stor-muted-text)]">Visitors see these in the photo viewer. Location is where the camera recorded each photo.</p>
310
+ <label class="flex min-h-11 items-center gap-3 sm:min-h-9"><Checkbox checked={shareDetails.camera} disabled={busy || Boolean(pending)} onCheckedChange={(checked) => shareDetails.camera = checked === true} /> Camera and exposure</label>
311
+ <label class="flex min-h-11 items-center gap-3 sm:min-h-9"><Checkbox checked={shareDetails.date} disabled={busy || Boolean(pending)} onCheckedChange={(checked) => shareDetails.date = checked === true} /> Date taken</label>
312
+ <label class="flex min-h-11 items-center gap-3 sm:min-h-9"><Checkbox checked={shareDetails.location} disabled={busy || Boolean(pending)} onCheckedChange={(checked) => shareDetails.location = checked === true} /> Location</label>
313
+ </fieldset>
314
+ {#if !targetId && publications.length > 0}
315
+ <label class="block font-medium">Publish as
316
+ <select bind:value={targetId} disabled={busy || Boolean(pending)} class="mt-1 h-11 w-full rounded-xl border border-[var(--stor-field-border)] bg-[var(--stor-surface-muted)] px-3 text-[var(--stor-ink)] sm:h-9">
317
+ <option value="">New public gallery</option>
318
+ {#each publications as item (item.id)}<option value={item.id}>Update {item.title}</option>{/each}
319
+ </select>
320
+ </label>
321
+ {/if}
322
+ </div>
323
+ <div>
324
+ <div class="flex flex-wrap items-center justify-between gap-2">
325
+ <h3 class="font-medium">Public preview · {draftPhotos.length} {draftPhotos.length === 1 ? 'photo' : 'photos'}</h3>
326
+ <div class="flex gap-1 rounded-full bg-[var(--stor-surface-muted)] p-1" role="group" aria-label="Preview width">
327
+ <button type="button" class={`rounded-full px-3 py-1 text-xs text-[var(--stor-ink)] ${previewMode === 'desktop' ? 'bg-[var(--stor-surface-raised)]' : ''}`} aria-pressed={previewMode === 'desktop'} onclick={() => previewMode = 'desktop'}>Desktop</button>
328
+ <button type="button" class={`rounded-full px-3 py-1 text-xs text-[var(--stor-ink)] ${previewMode === 'phone' ? 'bg-[var(--stor-surface-raised)]' : ''}`} aria-pressed={previewMode === 'phone'} onclick={() => previewMode = 'phone'}>Phone</button>
329
+ </div>
330
+ </div>
331
+ <p class="mt-1 text-[var(--stor-muted-text)]">Only these resized copies, the text below, and the photo details you keep will be public. The copies carry no embedded metadata; people and places in the pictures stay recognizable.</p>
332
+ {#if reviewPages > 1}
333
+ <div class="mt-3 flex items-center justify-between gap-2 text-xs text-[var(--stor-muted-text)]">
334
+ <Button variant="ghost" size="sm" disabled={reviewPage === 0} onclick={() => reviewPage -= 1}>Previous</Button>
335
+ <span>Review page {reviewPage + 1} of {reviewPages}</span>
336
+ <Button variant="ghost" size="sm" disabled={reviewPage + 1 >= reviewPages} onclick={() => reviewPage += 1}>Next</Button>
337
+ </div>
338
+ {/if}
339
+ <div class="preview mt-3" class:phone-preview={previewMode === 'phone'} bind:clientWidth={previewWidth}>
340
+ {#if reviewPage === 0 && draftPhotos[0]}
341
+ <div class="preview-cover" style:aspect-ratio={tileRatio(draftPhotos[0])}>
342
+ <PublicationPreviewPhoto {tenantId} {logId} photoId={draftPhotos[0].photoId} fileVersionId={draftPhotos[0].fileVersionId} alt={draftPhotos[0].alt} />
343
+ <span class="sr-only">Cover photo</span>
344
+ </div>
345
+ {/if}
346
+ <JustifiedRows items={reviewPage === 0 ? visibleReviewPhotos.slice(1) : visibleReviewPhotos} key={(photo) => photo.photoId} width={previewWidth} rowHeight={previewGeometry.rowHeight} gap={previewGeometry.gap}>
347
+ {#snippet tile(photo, size)}
348
+ <div class="preview-photo" style:width={size ? `${size.width}px` : '100%'} style:height={size ? `${size.height}px` : '100%'}>
349
+ <PublicationPreviewPhoto {tenantId} {logId} photoId={photo.photoId} fileVersionId={photo.fileVersionId} alt={photo.alt} />
350
+ </div>
351
+ {/snippet}
352
+ </JustifiedRows>
353
+ </div>
354
+ </div>
355
+ <div class="space-y-3">
356
+ <h3 class="font-medium">Order and public text</h3>
357
+ {#each visibleReviewPhotos as photo, localIndex (photo.photoId)}
358
+ {@const index = reviewPage * reviewPageSize + localIndex}
359
+ <div class="rounded-xl bg-[var(--stor-surface-muted)] p-3">
360
+ <div class="mb-2 flex items-center gap-2">
361
+ <span class="min-w-0 flex-1 truncate font-medium">{index + 1}. {photo.filename}{index === 0 ? ' · Cover' : ''}</span>
362
+ <Button variant="ghost" size="icon" aria-label={`Move ${photo.filename} earlier`} disabled={index === 0 || busy || Boolean(pending)} onclick={() => movePhoto(index, -1)}><IconArrowUp class="h-4 w-4" /></Button>
363
+ <Button variant="ghost" size="icon" aria-label={`Move ${photo.filename} later`} disabled={index === draftPhotos.length - 1 || busy || Boolean(pending)} onclick={() => movePhoto(index, 1)}><IconArrowDown class="h-4 w-4" /></Button>
364
+ <Button variant="ghost" size="icon" aria-label={`Exclude ${photo.filename}`} disabled={busy || Boolean(pending)} onclick={() => removePhoto(index)}><IconClose class="h-4 w-4" /></Button>
365
+ </div>
366
+ <label class="block text-xs">Public caption<Input bind:value={photo.caption} maxlength={500} class="mt-1" disabled={busy || Boolean(pending)} /></label>
367
+ <label class="mt-2 block text-xs">Image description<Input bind:value={photo.alt} maxlength={500} class="mt-1" disabled={busy || Boolean(pending)} /></label>
368
+ </div>
369
+ {/each}
370
+ </div>
371
+ {#if pending && !busy}<Button variant="ghost" onclick={() => { pending = null; draftError = ''; }}>Change review</Button>{/if}
372
+ <div class="flex justify-end"><Button disabled={busy || draftPhotos.length === 0} onclick={() => void publish()}>{busy ? 'Publishing…' : pending ? 'Retry publishing' : targetId ? 'Publish update' : 'Publish unlisted gallery'}</Button></div>
373
+ {/if}
374
+ {#if progress}<p role="status" class="text-[var(--stor-muted-text)]">{progress}</p>{/if}
375
+ {#if draftError}<p role="alert" class="text-[var(--stor-negative)]">{draftError}</p>{/if}
376
+ </div>
377
+ </CommandDrawer>
378
+
379
+ <CommandConfirmDialog show={Boolean(unpublishId)} title="Unpublish gallery?" message={`The public page and its images will stop opening from this link. Copies already saved by visitors remain outside Hearthlog's control.`} confirmLabel="Unpublish" onConfirm={() => void unpublish()} onCancel={() => unpublishId = ''} />
380
+
381
+ <style>
382
+ .preview { display: grid; gap: 5px; }
383
+ .preview-cover { width: 100%; max-height: 360px; overflow: hidden; border-radius: 8px; }
384
+ .preview-photo { flex: none; overflow: hidden; border-radius: 8px; }
385
+ .preview.phone-preview { max-width: 390px; }
386
+ </style>
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import { galleryRendition, type GalleryFileScope } from '../../gallery/file-store.js';
3
+
4
+ let { tenantId, logId, photoId, fileVersionId, alt }: {
5
+ tenantId: string;
6
+ logId: string;
7
+ photoId: string;
8
+ fileVersionId: string;
9
+ alt: string;
10
+ } = $props();
11
+ let url = $state('');
12
+ const scope = $derived<GalleryFileScope>({ tenantId, logId, photoId, fileVersionId });
13
+
14
+ $effect(() => {
15
+ if (!scope.tenantId || !scope.logId) return;
16
+ let cancelled = false;
17
+ let objectUrl = '';
18
+ void galleryRendition(scope, 'thumbnail').then((blob) => {
19
+ if (cancelled) return;
20
+ objectUrl = URL.createObjectURL(blob);
21
+ url = objectUrl;
22
+ }).catch(() => { if (!cancelled) url = ''; });
23
+ return () => {
24
+ cancelled = true;
25
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
26
+ url = '';
27
+ };
28
+ });
29
+ </script>
30
+
31
+ <div class="h-full w-full overflow-hidden rounded-lg bg-[var(--stor-surface-muted)]">
32
+ {#if url}<img src={url} {alt} class="h-full w-full object-cover" loading="lazy" />{/if}
33
+ </div>