@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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +41 -0
- package/package.json +42 -3
- package/public/app/App.svelte +448 -0
- package/public/app/ambient.d.ts +5 -0
- package/public/app/client.ts +7 -0
- package/public/app/types.ts +53 -0
- package/public/client-assets.ts +17 -0
- package/public/contract.ts +211 -0
- package/public/index.ts +237 -0
- package/public/tsconfig.json +21 -0
- package/src/components/gallery/AlbumCard.svelte +41 -0
- package/src/components/gallery/AlbumSection.svelte +323 -0
- package/src/components/gallery/GalleryMonthBlock.svelte +159 -0
- package/src/components/gallery/GalleryTimeline.svelte +294 -0
- package/src/components/gallery/PhotoThumb.svelte +26 -0
- package/src/components/gallery/PhotoTile.svelte +163 -0
- package/src/components/gallery/PhotoViewer.svelte +219 -0
- package/src/components/gallery/PublicationManager.svelte +386 -0
- package/src/components/gallery/PublicationPreviewPhoto.svelte +33 -0
- package/src/components/gallery/gallery-blocks.svelte.ts +30 -0
- package/src/components/gallery/view/GallerySpine.svelte +239 -0
- package/src/components/gallery/view/GalleryTile.svelte +157 -0
- package/src/components/gallery/view/JustifiedRows.svelte +54 -0
- package/src/components/gallery/view/MonthHeading.svelte +53 -0
- package/src/components/gallery/view/ViewerFrame.svelte +362 -0
- package/src/data/definitions.ts +93 -0
- package/src/data/gallery.ts +413 -0
- package/src/data/repositories.ts +8 -0
- package/src/gallery/backup-archive.ts +195 -0
- package/src/gallery/backup.ts +140 -0
- package/src/gallery/exif.ts +240 -0
- package/src/gallery/file-store.ts +206 -0
- package/src/gallery/format.ts +101 -0
- package/src/gallery/interaction.ts +6 -0
- package/src/gallery/layout.ts +67 -0
- package/src/gallery/public-details.ts +73 -0
- package/src/gallery/publication-api.ts +68 -0
- package/src/gallery/selection.ts +25 -0
- package/src/gallery/timeline.ts +77 -0
- package/src/pages/gallery.svelte +737 -0
- package/src/pages/gallery.ts +20 -0
- package/src/server/files.ts +229 -0
- package/src/server/index.ts +18 -0
- package/src/server/ledger.ts +42 -0
- package/src/server/publications.ts +576 -0
- package/src/startup.ts +2 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { queryDatabase } from '@hearthlog/sdk/data';
|
|
4
|
+
import type { GalleryAlbumSummary, GalleryFilter } from '../../data/gallery.js';
|
|
5
|
+
import type { GalleryAlbum, GalleryPhoto } from '@hearthlog/sdk/data';
|
|
6
|
+
import type { AddGalleryAlbumPhotoEvent, RemoveGalleryAlbumPhotoEvent } from '@hearthlog/sdk/data';
|
|
7
|
+
import { galleryAlbumCommands } from '@hearthlog/sdk/data';
|
|
8
|
+
import { executeLocalCommand } from '@hearthlog/sdk/data';
|
|
9
|
+
import { refreshPageData } from '@hearthlog/sdk/runtime';
|
|
10
|
+
import { notify } from '@hearthlog/sdk/runtime';
|
|
11
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
12
|
+
import { Input } from "@hearthlog/sdk/ui";
|
|
13
|
+
import { CommandOnly } from "@hearthlog/sdk/ui";
|
|
14
|
+
import { CommandModal } from "@hearthlog/sdk/ui";
|
|
15
|
+
import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
|
|
16
|
+
import { EmptyState } from "@hearthlog/sdk/ui";
|
|
17
|
+
import AlbumCard from './AlbumCard.svelte';
|
|
18
|
+
import GalleryTimeline, { type GalleryTimelineSource } from './GalleryTimeline.svelte';
|
|
19
|
+
import type { GalleryDensity } from '../../gallery/timeline.js';
|
|
20
|
+
import IconPlus from '~icons/mdi/plus';
|
|
21
|
+
import IconArrowLeft from '~icons/mdi/arrow-left';
|
|
22
|
+
import IconImage from '~icons/mdi/image-multiple-outline';
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
tenantId, logId, revision, albumId, density, onOpenAlbum, onOpenPhoto,
|
|
26
|
+
openCreate, onCloseCreate, onCreate, onPublish,
|
|
27
|
+
}: {
|
|
28
|
+
tenantId: string;
|
|
29
|
+
logId: string;
|
|
30
|
+
revision: number;
|
|
31
|
+
albumId: string;
|
|
32
|
+
density: GalleryDensity;
|
|
33
|
+
onOpenAlbum: (id: string) => void;
|
|
34
|
+
onOpenPhoto: (id: string, filter: GalleryFilter) => void;
|
|
35
|
+
openCreate: boolean;
|
|
36
|
+
onCloseCreate: () => void;
|
|
37
|
+
onCreate: () => void;
|
|
38
|
+
onPublish: (album: GalleryAlbum) => void;
|
|
39
|
+
} = $props();
|
|
40
|
+
|
|
41
|
+
let albums = $state<GalleryAlbumSummary[]>([]);
|
|
42
|
+
let album = $state<GalleryAlbum | null>(null);
|
|
43
|
+
/** The album's own timeline, newest first like Photos. */
|
|
44
|
+
let source = $state.raw<GalleryTimelineSource | null>(null);
|
|
45
|
+
let timeline = $state<ReturnType<typeof GalleryTimeline>>();
|
|
46
|
+
const photoCount = $derived(source?.summary.count ?? 0);
|
|
47
|
+
let candidates = $state<Array<GalleryPhoto & { in_album: number }>>([]);
|
|
48
|
+
let selectedCandidates = $state<Set<string>>(new Set());
|
|
49
|
+
let selectedMembers = $state<Set<string>>(new Set());
|
|
50
|
+
let selectMode = $state(false);
|
|
51
|
+
let addOpen = $state(false);
|
|
52
|
+
let editOpen = $state(false);
|
|
53
|
+
let deleteOpen = $state(false);
|
|
54
|
+
let title = $state('');
|
|
55
|
+
let description = $state('');
|
|
56
|
+
let chooseQuery = $state('');
|
|
57
|
+
let busy = $state(false);
|
|
58
|
+
let error = $state('');
|
|
59
|
+
let loadingCandidates = $state(false);
|
|
60
|
+
let candidatesMore = $state(false);
|
|
61
|
+
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (!tenantId || !logId) return;
|
|
64
|
+
void revision;
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
void queryDatabase((repositories) => repositories.gallery.albums()).then((result) => {
|
|
67
|
+
if (!cancelled) albums = result;
|
|
68
|
+
}).catch(() => { if (!cancelled) notify.error('Could not load albums'); });
|
|
69
|
+
return () => { cancelled = true; };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
$effect(() => {
|
|
73
|
+
const id = albumId;
|
|
74
|
+
if (!id || !tenantId || !logId) { album = null; source = null; return; }
|
|
75
|
+
void revision;
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
const filter: GalleryFilter = { albumId: id };
|
|
78
|
+
void queryDatabase(async (repositories) => ({
|
|
79
|
+
album: await repositories.gallery.album(id),
|
|
80
|
+
summary: await repositories.gallery.summary(filter),
|
|
81
|
+
})).then((result) => {
|
|
82
|
+
if (cancelled) return;
|
|
83
|
+
album = result.album;
|
|
84
|
+
source = { filter, summary: result.summary };
|
|
85
|
+
}).catch(() => { if (!cancelled) notify.error('Could not load album'); });
|
|
86
|
+
return () => { cancelled = true; };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
$effect(() => {
|
|
90
|
+
if (!addOpen || !albumId) return;
|
|
91
|
+
const id = albumId;
|
|
92
|
+
const search = chooseQuery;
|
|
93
|
+
void revision;
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
candidates = [];
|
|
96
|
+
candidatesMore = false;
|
|
97
|
+
void queryDatabase((repositories) => repositories.gallery.selectablePhotos(id, search)).then((result) => {
|
|
98
|
+
if (!cancelled) { candidates = result; candidatesMore = result.length === 100; }
|
|
99
|
+
}).catch(() => { if (!cancelled) notify.error('Could not load photos'); });
|
|
100
|
+
return () => { cancelled = true; };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function toggleCandidate(id: string) {
|
|
104
|
+
const next = new Set(selectedCandidates);
|
|
105
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
106
|
+
selectedCandidates = next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toggleMember(id: string) {
|
|
110
|
+
const next = new Set(selectedMembers);
|
|
111
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
112
|
+
selectedMembers = next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function selectMonth(month: string, select: boolean) {
|
|
116
|
+
if (!source) return;
|
|
117
|
+
const within = source.filter;
|
|
118
|
+
try {
|
|
119
|
+
const ids = await queryDatabase((repositories) => repositories.gallery.monthPhotoIds(month, within));
|
|
120
|
+
const next = new Set(selectedMembers);
|
|
121
|
+
for (const id of ids) {
|
|
122
|
+
if (select) next.add(id); else next.delete(id);
|
|
123
|
+
}
|
|
124
|
+
selectedMembers = next;
|
|
125
|
+
} catch { notify.error('Could not select the month'); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Brings a photo back into view after the viewer closes. */
|
|
129
|
+
export function revealPhoto(photo: GalleryPhoto): void {
|
|
130
|
+
void timeline?.revealPhoto(photo);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function beginCreate() { title = ''; description = ''; error = ''; }
|
|
134
|
+
$effect(() => { if (openCreate) untrack(beginCreate); });
|
|
135
|
+
|
|
136
|
+
function beginEdit() {
|
|
137
|
+
if (!album) return;
|
|
138
|
+
title = album.title;
|
|
139
|
+
description = album.description;
|
|
140
|
+
error = '';
|
|
141
|
+
editOpen = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveAlbum(edit: boolean) {
|
|
145
|
+
if (busy) return;
|
|
146
|
+
const cleanTitle = title.trim();
|
|
147
|
+
if (!cleanTitle) { error = 'Enter an album name.'; return; }
|
|
148
|
+
busy = true;
|
|
149
|
+
error = '';
|
|
150
|
+
try {
|
|
151
|
+
if (edit && album) {
|
|
152
|
+
await galleryAlbumCommands.update({ id: album.id, title: cleanTitle, description: description.trim() }, { submissionId: crypto.randomUUID() });
|
|
153
|
+
editOpen = false;
|
|
154
|
+
} else {
|
|
155
|
+
const id = crypto.randomUUID();
|
|
156
|
+
await galleryAlbumCommands.create({ id, title: cleanTitle, description: description.trim(), coverPhotoId: '' }, { submissionId: crypto.randomUUID() });
|
|
157
|
+
onCloseCreate();
|
|
158
|
+
onOpenAlbum(id);
|
|
159
|
+
}
|
|
160
|
+
await refreshPageData('app:gallery');
|
|
161
|
+
notify.success(edit ? 'Album updated' : 'Album created');
|
|
162
|
+
} catch (cause) { error = cause instanceof Error ? cause.message : 'Album could not be saved.'; }
|
|
163
|
+
finally { busy = false; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function removeAlbum() {
|
|
167
|
+
if (!album || busy) return;
|
|
168
|
+
busy = true;
|
|
169
|
+
try {
|
|
170
|
+
await galleryAlbumCommands.remove({ id: album.id }, { submissionId: crypto.randomUUID() });
|
|
171
|
+
deleteOpen = false;
|
|
172
|
+
onOpenAlbum('');
|
|
173
|
+
await refreshPageData('app:gallery');
|
|
174
|
+
notify.success('Album removed');
|
|
175
|
+
} catch { notify.error('Could not remove album'); }
|
|
176
|
+
finally { busy = false; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function addSelected() {
|
|
180
|
+
if (!album || selectedCandidates.size === 0 || busy) return;
|
|
181
|
+
busy = true;
|
|
182
|
+
try {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const events: AddGalleryAlbumPhotoEvent[] = [...selectedCandidates].map((photoId, index) => ({
|
|
185
|
+
id: crypto.randomUUID(), type: 'GALLERY_ALBUM_PHOTO_ADDED', timestamp: now + index,
|
|
186
|
+
payload: { id: crypto.randomUUID(), albumId: album!.id, photoId, addedAt: new Date(now + index).toISOString() },
|
|
187
|
+
}));
|
|
188
|
+
await executeLocalCommand(events, { submissionId: crypto.randomUUID() });
|
|
189
|
+
selectedCandidates = new Set();
|
|
190
|
+
addOpen = false;
|
|
191
|
+
await refreshPageData('app:gallery');
|
|
192
|
+
notify.success(events.length === 1 ? 'Photo added to album' : `${events.length} photos added to album`);
|
|
193
|
+
} catch (cause) { notify.error(cause instanceof Error ? cause.message : 'Could not add photos to album'); }
|
|
194
|
+
finally { busy = false; }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function removeSelected() {
|
|
198
|
+
if (!album || selectedMembers.size === 0 || busy) return;
|
|
199
|
+
busy = true;
|
|
200
|
+
try {
|
|
201
|
+
const memberships = await queryDatabase((repositories) => repositories.gallery.membershipsForPhotos(album!.id, [...selectedMembers]));
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const events: RemoveGalleryAlbumPhotoEvent[] = memberships.map((member, index) => ({
|
|
204
|
+
id: crypto.randomUUID(), type: 'GALLERY_ALBUM_PHOTO_REMOVED', timestamp: now + index,
|
|
205
|
+
payload: { id: member.id },
|
|
206
|
+
}));
|
|
207
|
+
if (events.length) await executeLocalCommand(events, { submissionId: crypto.randomUUID() });
|
|
208
|
+
selectedMembers = new Set();
|
|
209
|
+
selectMode = false;
|
|
210
|
+
await refreshPageData('app:gallery');
|
|
211
|
+
notify.success('Removed from album');
|
|
212
|
+
} catch { notify.error('Could not remove photos from album'); }
|
|
213
|
+
finally { busy = false; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadMoreCandidates() {
|
|
217
|
+
if (!albumId || candidates.length === 0 || loadingCandidates) return;
|
|
218
|
+
const last = candidates[candidates.length - 1];
|
|
219
|
+
const id = albumId;
|
|
220
|
+
const search = chooseQuery;
|
|
221
|
+
loadingCandidates = true;
|
|
222
|
+
try {
|
|
223
|
+
const next = await queryDatabase((repositories) => repositories.gallery.selectablePhotos(id, search, {
|
|
224
|
+
day: last.timeline_day, importedAt: last.imported_at, id: last.id,
|
|
225
|
+
}));
|
|
226
|
+
if (id === albumId && search === chooseQuery) {
|
|
227
|
+
candidates = [...candidates, ...next];
|
|
228
|
+
candidatesMore = next.length === 100;
|
|
229
|
+
}
|
|
230
|
+
} catch { notify.error('Could not load more photos'); }
|
|
231
|
+
finally { loadingCandidates = false; }
|
|
232
|
+
}
|
|
233
|
+
</script>
|
|
234
|
+
|
|
235
|
+
{#if albumId}
|
|
236
|
+
{#if album}
|
|
237
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
238
|
+
<Button variant="ghost" size="icon" aria-label="All albums" onclick={() => onOpenAlbum('')}><IconArrowLeft class="h-5 w-5" /></Button>
|
|
239
|
+
<div class="min-w-0 flex-1">
|
|
240
|
+
<h2 class="stor-serif-title truncate text-2xl text-[var(--stor-ink)]">{album.title}</h2>
|
|
241
|
+
<p class="text-sm text-[var(--stor-muted-text)]">{photoCount} {photoCount === 1 ? 'photo' : 'photos'}</p>
|
|
242
|
+
</div>
|
|
243
|
+
<CommandOnly>
|
|
244
|
+
{#if photoCount > 0}<Button variant="secondary" onclick={() => onPublish(album!)}>Publish album</Button>{/if}
|
|
245
|
+
<Button variant="secondary" onclick={() => { selectedCandidates = new Set(); chooseQuery = ''; addOpen = true; }}><IconPlus class="h-4 w-4" /> Add to album</Button>
|
|
246
|
+
<Button variant="ghost" onclick={() => { selectMode = !selectMode; selectedMembers = new Set(); }}>{selectMode ? 'Done selecting' : 'Select'}</Button>
|
|
247
|
+
<Button variant="ghost" onclick={beginEdit}>Edit</Button>
|
|
248
|
+
<Button variant="ghost" onclick={() => deleteOpen = true}>Remove album</Button>
|
|
249
|
+
</CommandOnly>
|
|
250
|
+
</div>
|
|
251
|
+
{#if selectMode && selectedMembers.size}
|
|
252
|
+
<div class="stor-panel flex shrink-0 items-center justify-between p-3 text-sm text-[var(--stor-ink)]">
|
|
253
|
+
<span>{selectedMembers.size} selected</span>
|
|
254
|
+
<CommandOnly><Button variant="destructive" onclick={() => void removeSelected()} disabled={busy}>Remove from album</Button></CommandOnly>
|
|
255
|
+
</div>
|
|
256
|
+
{/if}
|
|
257
|
+
{#if !source}
|
|
258
|
+
<div class="stor-panel p-6 text-sm text-[var(--stor-muted-text)]" role="status">Loading album…</div>
|
|
259
|
+
{:else if photoCount === 0}
|
|
260
|
+
<EmptyState icon={IconImage} title="Album is empty" description="Choose photos from your private gallery to add them here." />
|
|
261
|
+
{:else}
|
|
262
|
+
<section class="stor-panel relative min-h-0 flex-1" aria-label={`${album.title} photos`}>
|
|
263
|
+
<GalleryTimeline
|
|
264
|
+
bind:this={timeline}
|
|
265
|
+
{source}
|
|
266
|
+
{tenantId}
|
|
267
|
+
{logId}
|
|
268
|
+
{revision}
|
|
269
|
+
{density}
|
|
270
|
+
opening="start"
|
|
271
|
+
chips={false}
|
|
272
|
+
showLatest={false}
|
|
273
|
+
selecting={selectMode}
|
|
274
|
+
selectedIds={selectedMembers}
|
|
275
|
+
onOpen={(id) => selectMode ? toggleMember(id) : onOpenPhoto(id, source!.filter)}
|
|
276
|
+
onSelect={(id) => { selectMode = true; toggleMember(id); }}
|
|
277
|
+
onSelectMonth={(month, select) => void selectMonth(month, select)}
|
|
278
|
+
/>
|
|
279
|
+
</section>
|
|
280
|
+
{/if}
|
|
281
|
+
{:else}
|
|
282
|
+
<div class="stor-panel p-6 text-sm text-[var(--stor-muted-text)]" role="status">Loading album…</div>
|
|
283
|
+
{/if}
|
|
284
|
+
{:else if albums.length === 0}
|
|
285
|
+
<EmptyState icon={IconImage} title="No albums yet" description="Collect photos without making another copy of each file.">
|
|
286
|
+
<CommandOnly><Button onclick={onCreate}>New album</Button></CommandOnly>
|
|
287
|
+
</EmptyState>
|
|
288
|
+
{:else}
|
|
289
|
+
<div class="stor-panel grid grid-cols-2 gap-4 p-3 sm:grid-cols-3 sm:p-4 xl:grid-cols-4">
|
|
290
|
+
{#each albums as item (item.id)}
|
|
291
|
+
<AlbumCard album={item} {tenantId} {logId} onOpen={() => onOpenAlbum(item.id)} />
|
|
292
|
+
{/each}
|
|
293
|
+
</div>
|
|
294
|
+
{/if}
|
|
295
|
+
|
|
296
|
+
<CommandModal show={openCreate || editOpen} title={editOpen ? 'Edit album' : 'New album'} onClose={() => { editOpen = false; onCloseCreate(); }}>
|
|
297
|
+
<form onsubmit={(event) => { event.preventDefault(); void saveAlbum(editOpen); }} class="space-y-4">
|
|
298
|
+
<label class="block text-sm font-medium text-[var(--stor-ink)]">Name<Input bind:value={title} maxlength={120} required class="mt-1" /></label>
|
|
299
|
+
<label class="block text-sm font-medium text-[var(--stor-ink)]">Description<Input bind:value={description} maxlength={240} class="mt-1" /></label>
|
|
300
|
+
{#if error}<p role="alert" class="text-sm text-[var(--stor-negative)]">{error}</p>{/if}
|
|
301
|
+
<div class="flex justify-end"><Button type="submit" disabled={busy}>{busy ? 'Saving…' : editOpen ? 'Save album' : 'Create album'}</Button></div>
|
|
302
|
+
</form>
|
|
303
|
+
</CommandModal>
|
|
304
|
+
|
|
305
|
+
<CommandModal show={addOpen} title="Add photos to album" onClose={() => { addOpen = false; selectedCandidates = new Set(); }}>
|
|
306
|
+
<div class="space-y-3">
|
|
307
|
+
<Input type="search" bind:value={chooseQuery} placeholder="Search photos" aria-label="Search photos to add" />
|
|
308
|
+
<div class="max-h-[55vh] space-y-1 overflow-y-auto">
|
|
309
|
+
{#each candidates as candidate (candidate.id)}
|
|
310
|
+
<label class="flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm text-[var(--stor-ink)] hover:bg-[var(--stor-surface-muted)]">
|
|
311
|
+
<input type="checkbox" checked={candidate.in_album ? true : selectedCandidates.has(candidate.id)} disabled={Boolean(candidate.in_album)} onchange={() => toggleCandidate(candidate.id)} />
|
|
312
|
+
<span class="min-w-0 flex-1 truncate">{candidate.filename}</span>
|
|
313
|
+
{#if candidate.in_album}<span class="text-xs text-[var(--stor-muted-text)]">Added</span>{/if}
|
|
314
|
+
</label>
|
|
315
|
+
{/each}
|
|
316
|
+
{#if candidates.length === 0}<p class="py-4 text-sm text-[var(--stor-muted-text)]">No photos found.</p>{/if}
|
|
317
|
+
{#if candidatesMore}<div class="py-2 text-center"><Button variant="secondary" disabled={loadingCandidates} onclick={() => void loadMoreCandidates()}>{loadingCandidates ? 'Loading…' : 'Load more photos'}</Button></div>{/if}
|
|
318
|
+
</div>
|
|
319
|
+
<div class="flex justify-end"><Button disabled={busy || selectedCandidates.size === 0} onclick={() => void addSelected()}>Add {selectedCandidates.size || ''} {selectedCandidates.size === 1 ? 'photo' : 'photos'}</Button></div>
|
|
320
|
+
</div>
|
|
321
|
+
</CommandModal>
|
|
322
|
+
|
|
323
|
+
<CommandConfirmDialog show={deleteOpen} title="Remove album?" message="Photos stay in your gallery. This album can be recovered from log history." confirmLabel="Remove album" onConfirm={() => void removeAlbum()} onCancel={() => deleteOpen = false} />
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { GalleryBlockSummary } from '../../data/gallery.js';
|
|
3
|
+
import type { GalleryBlockData } from './gallery-blocks.svelte.js';
|
|
4
|
+
import { formatMonthParts } from '../../gallery/format.js';
|
|
5
|
+
import type { GalleryGeometry } from '../../gallery/timeline.js';
|
|
6
|
+
import JustifiedRows from './view/JustifiedRows.svelte';
|
|
7
|
+
import MonthHeading from './view/MonthHeading.svelte';
|
|
8
|
+
import PhotoTile from './PhotoTile.svelte';
|
|
9
|
+
import IconCheck from '~icons/mdi/check';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
block,
|
|
13
|
+
data,
|
|
14
|
+
geometry,
|
|
15
|
+
tenantId,
|
|
16
|
+
logId,
|
|
17
|
+
beginning = '',
|
|
18
|
+
last = false,
|
|
19
|
+
selecting = false,
|
|
20
|
+
selectedIds,
|
|
21
|
+
monthSelected = false,
|
|
22
|
+
onOpen,
|
|
23
|
+
onSelect,
|
|
24
|
+
onSelectMonth,
|
|
25
|
+
onOpenAlbum,
|
|
26
|
+
}: {
|
|
27
|
+
block: GalleryBlockSummary;
|
|
28
|
+
/** Missing while the block loads. */
|
|
29
|
+
data: GalleryBlockData | undefined;
|
|
30
|
+
geometry: GalleryGeometry;
|
|
31
|
+
tenantId: string;
|
|
32
|
+
logId: string;
|
|
33
|
+
/** A line above the first month of the timeline. */
|
|
34
|
+
beginning?: string;
|
|
35
|
+
/** The timeline's last (oldest) block; the selection bar floats past it. */
|
|
36
|
+
last?: boolean;
|
|
37
|
+
selecting?: boolean;
|
|
38
|
+
selectedIds: Set<string>;
|
|
39
|
+
monthSelected?: boolean;
|
|
40
|
+
onOpen: (id: string) => void;
|
|
41
|
+
onSelect: (id: string) => void;
|
|
42
|
+
onSelectMonth: (month: string, select: boolean) => void;
|
|
43
|
+
onOpenAlbum?: (id: string) => void;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
const month = $derived(formatMonthParts(block.month));
|
|
47
|
+
/** Placeholder rows shaped like the estimate, so nothing jumps when the photos arrive. */
|
|
48
|
+
const placeholder = $derived.by(() => {
|
|
49
|
+
const count = Math.max(1, Math.round((block.ratio_sum * geometry.rowHeight + block.photos * geometry.gap) / (geometry.width + geometry.gap) + 0.4));
|
|
50
|
+
const perRow = Math.max(1, Math.ceil(block.photos / count));
|
|
51
|
+
return Array.from({ length: count }, (_, row) => Math.max(1, Math.min(perRow, block.photos - row * perRow)));
|
|
52
|
+
});
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
class="gallery-block"
|
|
57
|
+
class:month-end={block.part === block.parts - 1}
|
|
58
|
+
class:selection-room={last && selecting}
|
|
59
|
+
style:--gallery-gap={`${geometry.gap}px`}
|
|
60
|
+
style:--gallery-header={`${geometry.header}px`}
|
|
61
|
+
style:--gallery-month-gap={`${geometry.monthGap}px`}
|
|
62
|
+
aria-busy={!data}
|
|
63
|
+
>
|
|
64
|
+
{#if beginning}<p class="gallery-beginning stor-serif-title">{beginning}</p>{/if}
|
|
65
|
+
{#if block.part === 0}
|
|
66
|
+
<MonthHeading month={block.month} count={block.month_photos}>
|
|
67
|
+
{#snippet before()}
|
|
68
|
+
{#if selecting}
|
|
69
|
+
<button type="button" class="gallery-month-check" aria-pressed={monthSelected} aria-label={`Select all of ${month.name} ${month.year}`} onclick={() => onSelectMonth(block.month, !monthSelected)}>
|
|
70
|
+
<IconCheck class="h-3.5 w-3.5" />
|
|
71
|
+
</button>
|
|
72
|
+
{/if}
|
|
73
|
+
{/snippet}
|
|
74
|
+
{#snippet after()}
|
|
75
|
+
{#if data?.albums.length && onOpenAlbum}
|
|
76
|
+
<span class="gallery-month-albums">
|
|
77
|
+
{#each data.albums as album (album.id)}
|
|
78
|
+
<button type="button" class="gallery-chip" onclick={() => onOpenAlbum(album.id)}>{album.title}</button>
|
|
79
|
+
{/each}
|
|
80
|
+
</span>
|
|
81
|
+
{/if}
|
|
82
|
+
{/snippet}
|
|
83
|
+
</MonthHeading>
|
|
84
|
+
{/if}
|
|
85
|
+
{#if data}
|
|
86
|
+
<JustifiedRows items={data.photos} key={(photo) => photo.id} width={geometry.width} rowHeight={geometry.rowHeight} gap={geometry.gap}>
|
|
87
|
+
{#snippet tile(photo, size)}
|
|
88
|
+
<PhotoTile
|
|
89
|
+
{photo}
|
|
90
|
+
{tenantId}
|
|
91
|
+
{logId}
|
|
92
|
+
width={size?.width ?? 0}
|
|
93
|
+
height={size?.height ?? 0}
|
|
94
|
+
selectable={selecting}
|
|
95
|
+
selected={selectedIds.has(photo.id)}
|
|
96
|
+
onOpen={() => onOpen(photo.id)}
|
|
97
|
+
onSelect={() => onSelect(photo.id)}
|
|
98
|
+
/>
|
|
99
|
+
{/snippet}
|
|
100
|
+
</JustifiedRows>
|
|
101
|
+
{:else}
|
|
102
|
+
<div class="gallery-rows">
|
|
103
|
+
{#each placeholder as cells, row (row)}
|
|
104
|
+
<div class="gallery-row" style:height={`${geometry.rowHeight}px`} role={row === 0 ? 'status' : undefined} aria-label={row === 0 ? 'Loading photos' : undefined}>
|
|
105
|
+
{#each Array.from({ length: cells }) as _, cell (cell)}
|
|
106
|
+
<span class="gallery-placeholder motion-safe:animate-pulse" style:flex-grow={1 + ((cell * 7 + row * 3) % 5) / 6}></span>
|
|
107
|
+
{/each}
|
|
108
|
+
</div>
|
|
109
|
+
{/each}
|
|
110
|
+
</div>
|
|
111
|
+
{/if}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.gallery-block { padding-inline: var(--gallery-inset-start) var(--gallery-inset-end); }
|
|
116
|
+
.gallery-block.month-end { padding-bottom: var(--gallery-month-gap); }
|
|
117
|
+
/* The selection bar floats over the list's end; the last photos stay reachable above it. */
|
|
118
|
+
.gallery-block.selection-room { padding-bottom: calc(var(--gallery-month-gap) + 64px); }
|
|
119
|
+
.gallery-beginning {
|
|
120
|
+
padding: 28px 0 6px;
|
|
121
|
+
color: var(--stor-muted-text);
|
|
122
|
+
font-size: 16px;
|
|
123
|
+
font-style: italic;
|
|
124
|
+
font-weight: 500;
|
|
125
|
+
text-align: center;
|
|
126
|
+
}
|
|
127
|
+
.gallery-month-albums { display: flex; min-width: 0; gap: 4px; overflow: hidden; }
|
|
128
|
+
.gallery-chip {
|
|
129
|
+
max-width: 14em;
|
|
130
|
+
flex: none;
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
border-radius: 999px;
|
|
133
|
+
background: var(--stor-surface-muted);
|
|
134
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--stor-border) 60%, transparent);
|
|
135
|
+
padding: 5px 10px;
|
|
136
|
+
color: var(--stor-muted-text);
|
|
137
|
+
font-size: 11.5px;
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
line-height: 1;
|
|
140
|
+
text-overflow: ellipsis;
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
}
|
|
143
|
+
.gallery-chip:hover { background: var(--stor-accent-soft); color: var(--stor-accent-strong); }
|
|
144
|
+
.gallery-chip:focus-visible, .gallery-month-check:focus-visible { outline: 2px solid var(--stor-focus); outline-offset: 2px; }
|
|
145
|
+
.gallery-month-check {
|
|
146
|
+
display: grid;
|
|
147
|
+
width: 24px;
|
|
148
|
+
height: 24px;
|
|
149
|
+
flex: none;
|
|
150
|
+
place-items: center;
|
|
151
|
+
border-radius: 999px;
|
|
152
|
+
box-shadow: inset 0 0 0 1.5px var(--stor-border);
|
|
153
|
+
color: transparent;
|
|
154
|
+
}
|
|
155
|
+
.gallery-month-check[aria-pressed='true'] { background: var(--stor-accent); box-shadow: none; color: var(--stor-action-text, #fff); }
|
|
156
|
+
.gallery-rows { display: flex; flex-direction: column; gap: var(--gallery-gap); }
|
|
157
|
+
.gallery-row { display: flex; gap: var(--gallery-gap); }
|
|
158
|
+
.gallery-placeholder { flex-basis: 0; border-radius: 8px; background: var(--stor-surface-muted); }
|
|
159
|
+
</style>
|