@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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +72 -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,737 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick } from 'svelte';
|
|
3
|
+
import { page } from '$app/state';
|
|
4
|
+
import { goto } from '$app/navigation';
|
|
5
|
+
import { DropdownMenu, Tabs } from 'bits-ui';
|
|
6
|
+
import { tabIndicator } from '@hearthlog/sdk/ui';
|
|
7
|
+
import { PageHeader } from "@hearthlog/sdk/ui";
|
|
8
|
+
import { PageDataStatus } from "@hearthlog/sdk/ui";
|
|
9
|
+
import { MobilePageAction } from "@hearthlog/sdk/ui";
|
|
10
|
+
import { EmptyState } from "@hearthlog/sdk/ui";
|
|
11
|
+
import { CommandOnly } from "@hearthlog/sdk/ui";
|
|
12
|
+
import { CommandModal } from "@hearthlog/sdk/ui";
|
|
13
|
+
import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
|
|
14
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
15
|
+
import { Select } from "@hearthlog/sdk/ui";
|
|
16
|
+
import { Textarea } from "@hearthlog/sdk/ui";
|
|
17
|
+
import { DataPlaceholder } from "@hearthlog/sdk/ui";
|
|
18
|
+
import GalleryTimeline, { type GalleryOpening, type GalleryTimelineSource } from '../components/gallery/GalleryTimeline.svelte';
|
|
19
|
+
import { formatMonthParts, formatPhotoCount } from '../gallery/format.js';
|
|
20
|
+
import PhotoViewer from '../components/gallery/PhotoViewer.svelte';
|
|
21
|
+
import AlbumSection from '../components/gallery/AlbumSection.svelte';
|
|
22
|
+
import PublicationManager, { type PublicationSource } from '../components/gallery/PublicationManager.svelte';
|
|
23
|
+
import { providePageResource } from '@hearthlog/sdk/runtime';
|
|
24
|
+
import { refreshPageData } from '@hearthlog/sdk/runtime';
|
|
25
|
+
import { runCommandAction } from '@hearthlog/sdk/runtime';
|
|
26
|
+
import { queryDatabase } from '@hearthlog/sdk/data';
|
|
27
|
+
import { requireActiveContext } from '@hearthlog/sdk/runtime';
|
|
28
|
+
import { galleryPhotoCommands } from '@hearthlog/sdk/data';
|
|
29
|
+
import { executeLocalCommand } from '@hearthlog/sdk/data';
|
|
30
|
+
import type { AddGalleryAlbumPhotoEvent } from '@hearthlog/sdk/data';
|
|
31
|
+
import { notify } from '@hearthlog/sdk/runtime';
|
|
32
|
+
import { appClock } from '@hearthlog/sdk/runtime';
|
|
33
|
+
import { tenant } from '@hearthlog/sdk/runtime';
|
|
34
|
+
import {
|
|
35
|
+
stageGalleryOriginal, discardStagedGalleryOriginal, triggerGalleryTransfers,
|
|
36
|
+
type GalleryFileScope,
|
|
37
|
+
} from '../gallery/file-store.js';
|
|
38
|
+
import { readPhotoMetadata } from '../gallery/exif.js';
|
|
39
|
+
import { exportGalleryBackup, restoreGalleryBackup } from '../gallery/backup.js';
|
|
40
|
+
import { readGalleryArchive } from '../gallery/backup-archive.js';
|
|
41
|
+
import { GALLERY_DENSITY_KEY, parseGalleryDensity, type GalleryDensity } from '../gallery/timeline.js';
|
|
42
|
+
import { getDeviceSetting as kvGet, setDeviceSetting as kvSet } from '@hearthlog/sdk/runtime';
|
|
43
|
+
import { gallerySelectionToken, readGallerySelection, writeGallerySelection } from '../gallery/selection.js';
|
|
44
|
+
import type { GalleryAlbumSummary, GalleryFilter } from '../data/gallery.js';
|
|
45
|
+
import type { GalleryPhoto } from '@hearthlog/sdk/data';
|
|
46
|
+
import IconPlus from '~icons/mdi/plus';
|
|
47
|
+
import IconImage from '~icons/mdi/image-multiple-outline';
|
|
48
|
+
import IconSearch from '~icons/mdi/magnify';
|
|
49
|
+
import IconArchive from '~icons/mdi/archive-arrow-down-outline';
|
|
50
|
+
import IconDotsVertical from '~icons/mdi/dots-vertical';
|
|
51
|
+
import IconMinus from '~icons/mdi/minus';
|
|
52
|
+
|
|
53
|
+
let { data: routeData } = $props();
|
|
54
|
+
const resource = $derived(routeData.pageResource);
|
|
55
|
+
providePageResource(() => resource);
|
|
56
|
+
const data = $derived(resource.data);
|
|
57
|
+
|
|
58
|
+
let tab = $state<'photos' | 'favorites' | 'albums' | 'shared'>(page.url.searchParams.get('tab') === 'shared' ? 'shared' : page.url.searchParams.get('tab') === 'albums' ? 'albums' : page.url.searchParams.get('tab') === 'favorites' ? 'favorites' : 'photos');
|
|
59
|
+
let albumId = $state(page.url.searchParams.get('album') ?? '');
|
|
60
|
+
let createAlbumOpen = $state(false);
|
|
61
|
+
let publishSource = $state<PublicationSource | null>(null);
|
|
62
|
+
let selectionToken = $state(gallerySelectionToken(page.url.searchParams));
|
|
63
|
+
const selectMode = $derived(Boolean(selectionToken));
|
|
64
|
+
let selectedPhotoIds = $state<Set<string>>(new Set());
|
|
65
|
+
let albumChoices = $state<GalleryAlbumSummary[]>([]);
|
|
66
|
+
let selectedAlbumId = $state('');
|
|
67
|
+
let addAlbumOpen = $state(false);
|
|
68
|
+
let addAlbumLoading = $state(false);
|
|
69
|
+
let selectionBusy = $state(false);
|
|
70
|
+
let selectionError = $state('');
|
|
71
|
+
let pendingAlbumEvents: AddGalleryAlbumPhotoEvent[] | null = null;
|
|
72
|
+
let pendingAlbumSubmissionId = '';
|
|
73
|
+
let query = $state(page.url.searchParams.get('q') ?? '');
|
|
74
|
+
let selectedId = $state(page.url.searchParams.get('photo') ?? '');
|
|
75
|
+
let selected = $state<GalleryPhoto | null>(null);
|
|
76
|
+
/** The timeline a photo was opened from; the viewer's arrows stay within it. */
|
|
77
|
+
let viewerFilter = $state<GalleryFilter>({});
|
|
78
|
+
let busy = $state(false);
|
|
79
|
+
let importError = $state('');
|
|
80
|
+
let editOpen = $state(false);
|
|
81
|
+
let editingPhoto = $state<GalleryPhoto | null>(null);
|
|
82
|
+
let removeOpen = $state(false);
|
|
83
|
+
let captionDraft = $state('');
|
|
84
|
+
let editError = $state('');
|
|
85
|
+
let backupOpen = $state(false);
|
|
86
|
+
let backupBusy = $state(false);
|
|
87
|
+
let backupStatus = $state('');
|
|
88
|
+
let backupError = $state('');
|
|
89
|
+
let restoreFile = $state<File | null>(null);
|
|
90
|
+
let restoreCounts = $state<{ photos: number; albums: number } | null>(null);
|
|
91
|
+
let fileInput: HTMLInputElement;
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------- the timeline
|
|
94
|
+
|
|
95
|
+
/** Photos and Favorites read one timeline, narrowed by search. */
|
|
96
|
+
const filter = $derived<GalleryFilter>({ query: query.trim(), favoritesOnly: tab === 'favorites' });
|
|
97
|
+
let source = $state.raw<GalleryTimelineSource | null>(null);
|
|
98
|
+
let timeline = $state<ReturnType<typeof GalleryTimeline>>();
|
|
99
|
+
let albumSection = $state<ReturnType<typeof AlbumSection>>();
|
|
100
|
+
/** Where the timeline opens: the month in the address on load, the newest photos after a new filter. */
|
|
101
|
+
let opening = $state<GalleryOpening>(page.url.searchParams.get('month') ?? 'start');
|
|
102
|
+
let density = $state<GalleryDensity>(1);
|
|
103
|
+
/** A photo to bring into view once the timeline lists it, after an import. */
|
|
104
|
+
let revealAfterLoad: GalleryPhoto | null = null;
|
|
105
|
+
|
|
106
|
+
onMount(() => {
|
|
107
|
+
void kvGet<unknown>(GALLERY_DENSITY_KEY).then((stored) => density = parseGalleryDensity(stored)).catch(() => undefined);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function changeDensity(next: GalleryDensity) {
|
|
111
|
+
density = next;
|
|
112
|
+
void kvSet(GALLERY_DENSITY_KEY, next).catch(() => undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The summary for the view, read after every load and whenever the view changes.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
if (resource.loading || tab === 'albums' || tab === 'shared') return;
|
|
118
|
+
const current = filter;
|
|
119
|
+
void resource.revision;
|
|
120
|
+
let cancelled = false;
|
|
121
|
+
void queryDatabase((repositories) => repositories.gallery.summary(current)).then((summary) => {
|
|
122
|
+
if (!cancelled) source = { filter: current, summary };
|
|
123
|
+
}).catch(() => {
|
|
124
|
+
if (!cancelled) notify.error('Could not load photos');
|
|
125
|
+
});
|
|
126
|
+
return () => { cancelled = true; };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
$effect(() => {
|
|
130
|
+
if (!source || !revealAfterLoad) return;
|
|
131
|
+
const photo = revealAfterLoad;
|
|
132
|
+
revealAfterLoad = null;
|
|
133
|
+
void tick().then(() => timeline?.revealPhoto(photo));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const beginning = $derived.by(() => {
|
|
137
|
+
const oldest = source?.summary.months[source.summary.months.length - 1];
|
|
138
|
+
if (!source || !oldest) return '';
|
|
139
|
+
const { name, year } = formatMonthParts(oldest.month);
|
|
140
|
+
if (source.filter.query) return `${formatPhotoCount(source.summary.count)} match “${source.filter.query}”`;
|
|
141
|
+
return `${source.filter.favoritesOnly ? `${source.summary.count} ${source.summary.count === 1 ? 'favorite' : 'favorites'}` : formatPhotoCount(source.summary.count)} since ${name} ${year}`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The address follows the month in view quietly, so a reload opens there.
|
|
145
|
+
let addressTimer: ReturnType<typeof setTimeout> | undefined;
|
|
146
|
+
function followMonth(month: string) {
|
|
147
|
+
clearTimeout(addressTimer);
|
|
148
|
+
addressTimer = setTimeout(() => {
|
|
149
|
+
const url = new URL(window.location.href);
|
|
150
|
+
if (url.searchParams.get('month') === month || (url.searchParams.get('tab') ?? 'photos') !== (tab === 'photos' ? 'photos' : tab)) return;
|
|
151
|
+
url.searchParams.set('month', month);
|
|
152
|
+
goto(url, { shallow: true, replace: true, state: page.state });
|
|
153
|
+
},
|
|
154
|
+
250
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
$effect(() => () => clearTimeout(addressTimer));
|
|
158
|
+
|
|
159
|
+
onMount(() => {
|
|
160
|
+
const sync = () => {
|
|
161
|
+
const url = new URL(window.location.href);
|
|
162
|
+
tab = url.searchParams.get('tab') === 'shared' ? 'shared' : url.searchParams.get('tab') === 'albums' ? 'albums' : url.searchParams.get('tab') === 'favorites' ? 'favorites' : 'photos';
|
|
163
|
+
albumId = url.searchParams.get('album') ?? '';
|
|
164
|
+
query = url.searchParams.get('q') ?? '';
|
|
165
|
+
selectedId = url.searchParams.get('photo') ?? '';
|
|
166
|
+
selectionToken = gallerySelectionToken(url.searchParams);
|
|
167
|
+
};
|
|
168
|
+
window.addEventListener('popstate', sync);
|
|
169
|
+
return () => window.removeEventListener('popstate', sync);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function updateUrl(name: string, value: string, history: 'push' | 'replace' = 'push') {
|
|
173
|
+
const url = new URL(window.location.href);
|
|
174
|
+
|
|
175
|
+
if (value) url.searchParams.set(name, value); else url.searchParams.delete(name);
|
|
176
|
+
if (history === 'push') goto(url, { shallow: true, state: page.state }); else goto(url, { shallow: true, replace: true, state: page.state });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function selectTab(value: 'photos' | 'favorites' | 'albums' | 'shared') {
|
|
180
|
+
tab = value;
|
|
181
|
+
selectionToken = '';
|
|
182
|
+
selectedPhotoIds = new Set();
|
|
183
|
+
opening = 'start';
|
|
184
|
+
const url = new URL(window.location.href);
|
|
185
|
+
if (value === 'photos') url.searchParams.delete('tab');
|
|
186
|
+
else url.searchParams.set('tab', value);
|
|
187
|
+
url.searchParams.delete('selection');
|
|
188
|
+
url.searchParams.delete('month');
|
|
189
|
+
goto(url, { shallow: true, state: page.state });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function openAlbum(id: string) {
|
|
193
|
+
albumId = id;
|
|
194
|
+
updateUrl('album', id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function openAlbumFromTimeline(id: string) {
|
|
198
|
+
selectTab('albums');
|
|
199
|
+
openAlbum(id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function toggleSelectedPhoto(id: string) {
|
|
203
|
+
const next = new Set(selectedPhotoIds);
|
|
204
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
205
|
+
selectedPhotoIds = next;
|
|
206
|
+
if (selectionToken) writeGallerySelection(sessionStorage, data.tenantId, data.logId, selectionToken, next);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function beginSelection() {
|
|
210
|
+
const token = crypto.randomUUID();
|
|
211
|
+
selectionToken = token;
|
|
212
|
+
selectedPhotoIds = new Set();
|
|
213
|
+
writeGallerySelection(sessionStorage, data.tenantId, data.logId, token, selectedPhotoIds);
|
|
214
|
+
updateUrl('selection', token);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The corner check selects a photo, starting a selection when none is open. */
|
|
218
|
+
function selectPhoto(id: string) {
|
|
219
|
+
if (!selectMode) beginSelection();
|
|
220
|
+
toggleSelectedPhoto(id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function selectMonth(month: string, select: boolean) {
|
|
224
|
+
const within = source?.filter ?? filter;
|
|
225
|
+
try {
|
|
226
|
+
const ids = await queryDatabase((repositories) => repositories.gallery.monthPhotoIds(month, within));
|
|
227
|
+
const next = new Set(selectedPhotoIds);
|
|
228
|
+
for (const id of ids) {
|
|
229
|
+
if (select) next.add(id); else next.delete(id);
|
|
230
|
+
}
|
|
231
|
+
selectedPhotoIds = next;
|
|
232
|
+
if (selectionToken) writeGallerySelection(sessionStorage, data.tenantId, data.logId, selectionToken, next);
|
|
233
|
+
} catch { notify.error('Could not select the month'); }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cancelSelection() {
|
|
237
|
+
selectionToken = '';
|
|
238
|
+
selectedPhotoIds = new Set();
|
|
239
|
+
updateUrl('selection', '');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function reviewSelected() {
|
|
243
|
+
if (selectedPhotoIds.size === 0) return;
|
|
244
|
+
publishSource = { photoIds: [...selectedPhotoIds], title: 'My photos', description: '' };
|
|
245
|
+
cancelSelection();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
$effect(() => {
|
|
249
|
+
const token = selectionToken;
|
|
250
|
+
const tenantId = data.tenantId;
|
|
251
|
+
const logId = data.logId;
|
|
252
|
+
if (!token || !tenantId || !logId) return;
|
|
253
|
+
selectedPhotoIds = readGallerySelection(sessionStorage, tenantId, logId, token);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
async function openAddToAlbum() {
|
|
257
|
+
if (!selectedPhotoIds.size) return;
|
|
258
|
+
addAlbumOpen = true;
|
|
259
|
+
selectionError = '';
|
|
260
|
+
selectedAlbumId = '';
|
|
261
|
+
albumChoices = [];
|
|
262
|
+
addAlbumLoading = true;
|
|
263
|
+
pendingAlbumEvents = null;
|
|
264
|
+
pendingAlbumSubmissionId = '';
|
|
265
|
+
try { albumChoices = await queryDatabase((repositories) => repositories.gallery.albums()); }
|
|
266
|
+
catch { selectionError = 'Albums could not be loaded.'; }
|
|
267
|
+
finally { addAlbumLoading = false; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function addSelectionToAlbum() {
|
|
271
|
+
if (!selectedAlbumId || !selectedPhotoIds.size || selectionBusy) return;
|
|
272
|
+
selectionBusy = true;
|
|
273
|
+
selectionError = '';
|
|
274
|
+
try {
|
|
275
|
+
const photoIds = pendingAlbumEvents
|
|
276
|
+
? pendingAlbumEvents.map((event) => event.payload.photoId)
|
|
277
|
+
: await queryDatabase((repositories) => repositories.gallery.addableAlbumPhotoIds(selectedAlbumId, [...selectedPhotoIds]));
|
|
278
|
+
if (photoIds.length === 0) {
|
|
279
|
+
notify.info('These photos are already in the album');
|
|
280
|
+
} else {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
pendingAlbumEvents ??= photoIds.map((photoId, index) => ({
|
|
283
|
+
id: crypto.randomUUID(), type: 'GALLERY_ALBUM_PHOTO_ADDED', timestamp: now + index,
|
|
284
|
+
payload: { id: crypto.randomUUID(), albumId: selectedAlbumId, photoId, addedAt: new Date(now + index).toISOString() },
|
|
285
|
+
}));
|
|
286
|
+
pendingAlbumSubmissionId ||= crypto.randomUUID();
|
|
287
|
+
await executeLocalCommand(pendingAlbumEvents, { submissionId: pendingAlbumSubmissionId });
|
|
288
|
+
await refreshPageData('app:gallery');
|
|
289
|
+
notify.success(photoIds.length === 1 ? 'Photo added to album' : `${photoIds.length} photos added to album`);
|
|
290
|
+
}
|
|
291
|
+
addAlbumOpen = false;
|
|
292
|
+
pendingAlbumEvents = null;
|
|
293
|
+
pendingAlbumSubmissionId = '';
|
|
294
|
+
cancelSelection();
|
|
295
|
+
} catch (cause) { selectionError = cause instanceof Error ? cause.message : 'Photos could not be added.'; }
|
|
296
|
+
finally { selectionBusy = false; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setQuery(value: string) {
|
|
300
|
+
query = value;
|
|
301
|
+
opening = 'start';
|
|
302
|
+
updateUrl('q', value.trim(), 'replace');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function openPhoto(id: string, within: GalleryFilter = source?.filter ?? filter) {
|
|
306
|
+
viewerFilter = within;
|
|
307
|
+
selectedId = id;
|
|
308
|
+
updateUrl('photo', id);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function closePhoto() {
|
|
312
|
+
const photo = selected;
|
|
313
|
+
selectedId = '';
|
|
314
|
+
updateUrl('photo', '', 'replace');
|
|
315
|
+
// Back in the timeline, the photo last shown is in view and focused.
|
|
316
|
+
if (photo) void tick().then(() => (tab === 'albums' ? albumSection : timeline)?.revealPhoto(photo));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
$effect(() => {
|
|
320
|
+
if (!selectedId || resource.loading) { if (!selectedId) selected = null; return; }
|
|
321
|
+
const id = selectedId;
|
|
322
|
+
void resource.revision;
|
|
323
|
+
let cancelled = false;
|
|
324
|
+
void queryDatabase((repositories) => repositories.gallery.get(id)).then((photo) => {
|
|
325
|
+
if (!cancelled) selected = photo;
|
|
326
|
+
});
|
|
327
|
+
return () => { cancelled = true; };
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
async function imageDimensions(file: File): Promise<{ width: number; height: number }> {
|
|
331
|
+
try {
|
|
332
|
+
const image = await createImageBitmap(file);
|
|
333
|
+
const dimensions = { width: image.width, height: image.height };
|
|
334
|
+
image.close();
|
|
335
|
+
return dimensions;
|
|
336
|
+
} catch {
|
|
337
|
+
// Keep the original when a browser cannot decode HEIC/HEIF.
|
|
338
|
+
return { width: 0, height: 0 };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function addPhotos(files: FileList | null) {
|
|
343
|
+
if (!files?.length || busy) return;
|
|
344
|
+
busy = true;
|
|
345
|
+
importError = '';
|
|
346
|
+
let added = 0;
|
|
347
|
+
let first: GalleryPhoto | null = null;
|
|
348
|
+
try {
|
|
349
|
+
for (const file of Array.from(files)) {
|
|
350
|
+
if (!['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'].includes(file.type) || file.size === 0 || file.size > 20 * 1024 * 1024) {
|
|
351
|
+
throw new Error(`${file.name}: use a supported photo up to 20 MiB.`);
|
|
352
|
+
}
|
|
353
|
+
const context = await requireActiveContext();
|
|
354
|
+
const photoId = crypto.randomUUID();
|
|
355
|
+
const fileVersionId = crypto.randomUUID();
|
|
356
|
+
const scope: GalleryFileScope = { tenantId: context.tenantId, logId: context.logId, photoId, fileVersionId };
|
|
357
|
+
const dimensions = await imageDimensions(file);
|
|
358
|
+
const { capturedDay, ...metadata } = readPhotoMetadata(new Uint8Array(await file.arrayBuffer()));
|
|
359
|
+
const now = await appClock.ready();
|
|
360
|
+
const importedAt = now.toISOString();
|
|
361
|
+
// The camera's capture day places the photo; the import day is the fallback.
|
|
362
|
+
const timelineDay = capturedDay || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
363
|
+
await stageGalleryOriginal(scope, file);
|
|
364
|
+
try {
|
|
365
|
+
await galleryPhotoCommands.create({
|
|
366
|
+
id: photoId, fileVersionId, filename: file.name, contentType: file.type,
|
|
367
|
+
byteSize: file.size, ...dimensions, ...metadata, importedAt,
|
|
368
|
+
timelineDay, caption: '', favorite: false,
|
|
369
|
+
}, { submissionId: crypto.randomUUID() });
|
|
370
|
+
first ??= { id: photoId, timeline_day: timelineDay, imported_at: importedAt } as GalleryPhoto;
|
|
371
|
+
} catch (cause) {
|
|
372
|
+
await discardStagedGalleryOriginal(scope);
|
|
373
|
+
throw cause;
|
|
374
|
+
}
|
|
375
|
+
added += 1;
|
|
376
|
+
}
|
|
377
|
+
// New photos land at their capture month; the timeline turns to the first one.
|
|
378
|
+
if (tab !== 'photos' || query.trim()) {
|
|
379
|
+
if (query.trim()) setQuery('');
|
|
380
|
+
if (tab !== 'photos') selectTab('photos');
|
|
381
|
+
}
|
|
382
|
+
revealAfterLoad = first;
|
|
383
|
+
await refreshPageData('app:gallery');
|
|
384
|
+
notify.success(added === 1 ? 'Photo saved on this device' : `${added} photos saved on this device`);
|
|
385
|
+
triggerGalleryTransfers();
|
|
386
|
+
} catch (cause) {
|
|
387
|
+
importError = cause instanceof Error ? cause.message : 'Photos could not be saved.';
|
|
388
|
+
if (added) await refreshPageData('app:gallery');
|
|
389
|
+
} finally {
|
|
390
|
+
busy = false;
|
|
391
|
+
if (fileInput) fileInput.value = '';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function toggleFavorite() {
|
|
396
|
+
if (!selected || busy) return;
|
|
397
|
+
busy = true;
|
|
398
|
+
try {
|
|
399
|
+
await galleryPhotoCommands.update({ id: selected.id, favorite: !selected.favorite }, { submissionId: crypto.randomUUID() });
|
|
400
|
+
selected = { ...selected, favorite: selected.favorite ? 0 : 1 };
|
|
401
|
+
await refreshPageData('app:gallery');
|
|
402
|
+
} catch { notify.error('Could not update favorite'); }
|
|
403
|
+
finally { busy = false; }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function editPhoto() {
|
|
407
|
+
if (!selected) return;
|
|
408
|
+
editingPhoto = selected;
|
|
409
|
+
captionDraft = selected.caption;
|
|
410
|
+
editError = '';
|
|
411
|
+
closePhoto();
|
|
412
|
+
selected = null;
|
|
413
|
+
editOpen = true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function closeEdit() {
|
|
417
|
+
editOpen = false;
|
|
418
|
+
if (editingPhoto) openPhoto(editingPhoto.id);
|
|
419
|
+
editingPhoto = null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function savePhoto() {
|
|
423
|
+
if (!editingPhoto || busy) return;
|
|
424
|
+
busy = true;
|
|
425
|
+
editError = '';
|
|
426
|
+
try {
|
|
427
|
+
const caption = captionDraft.trim();
|
|
428
|
+
await galleryPhotoCommands.update({ id: editingPhoto.id, caption }, { submissionId: crypto.randomUUID() });
|
|
429
|
+
await refreshPageData('app:gallery');
|
|
430
|
+
closeEdit();
|
|
431
|
+
notify.success('Photo updated');
|
|
432
|
+
} catch (cause) { editError = cause instanceof Error ? cause.message : 'Photo could not be updated.'; }
|
|
433
|
+
finally { busy = false; }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function removePhoto() {
|
|
437
|
+
if (!selected || busy) return;
|
|
438
|
+
busy = true;
|
|
439
|
+
try {
|
|
440
|
+
await galleryPhotoCommands.remove({ id: selected.id }, { submissionId: crypto.randomUUID() });
|
|
441
|
+
removeOpen = false;
|
|
442
|
+
closePhoto();
|
|
443
|
+
selected = null;
|
|
444
|
+
await refreshPageData('app:gallery');
|
|
445
|
+
notify.success('Photo removed');
|
|
446
|
+
} catch { notify.error('Could not remove photo'); }
|
|
447
|
+
finally { busy = false; }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function previewBackup(file: File | undefined) {
|
|
451
|
+
restoreFile = null;
|
|
452
|
+
restoreCounts = null;
|
|
453
|
+
backupError = '';
|
|
454
|
+
if (!file) return;
|
|
455
|
+
try {
|
|
456
|
+
const { manifest } = await readGalleryArchive(file);
|
|
457
|
+
restoreFile = file;
|
|
458
|
+
restoreCounts = { photos: manifest.photos.length, albums: manifest.albums.length };
|
|
459
|
+
} catch (cause) { backupError = cause instanceof Error ? cause.message : 'Backup could not be read.'; }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function downloadBackup() {
|
|
463
|
+
if (backupBusy) return;
|
|
464
|
+
backupBusy = true;
|
|
465
|
+
backupError = '';
|
|
466
|
+
try {
|
|
467
|
+
const count = await exportGalleryBackup((message) => backupStatus = message);
|
|
468
|
+
backupStatus = '';
|
|
469
|
+
notify.success(`${count} ${count === 1 ? 'photo' : 'photos'} backed up`);
|
|
470
|
+
} catch (cause) {
|
|
471
|
+
backupStatus = '';
|
|
472
|
+
if (!(cause instanceof DOMException && cause.name === 'AbortError')) {
|
|
473
|
+
backupError = cause instanceof Error ? cause.message : 'Backup could not be downloaded.';
|
|
474
|
+
}
|
|
475
|
+
} finally { backupBusy = false; }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function restoreBackup() {
|
|
479
|
+
if (!restoreFile || backupBusy) return;
|
|
480
|
+
backupBusy = true;
|
|
481
|
+
backupError = '';
|
|
482
|
+
try {
|
|
483
|
+
const result = await restoreGalleryBackup(restoreFile, (message) => backupStatus = message);
|
|
484
|
+
await refreshPageData('app:gallery');
|
|
485
|
+
backupOpen = false;
|
|
486
|
+
backupStatus = '';
|
|
487
|
+
notify.success(`${result.photos} ${result.photos === 1 ? 'photo' : 'photos'} and ${result.albums} ${result.albums === 1 ? 'album' : 'albums'} restored`);
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
await refreshPageData('app:gallery');
|
|
490
|
+
backupStatus = '';
|
|
491
|
+
backupError = cause instanceof Error ? cause.message : 'Backup could not be restored.';
|
|
492
|
+
} finally { backupBusy = false; }
|
|
493
|
+
}
|
|
494
|
+
</script>
|
|
495
|
+
|
|
496
|
+
<svelte:head><title>Gallery | {tenant.title}</title></svelte:head>
|
|
497
|
+
<PageDataStatus resource={resource} />
|
|
498
|
+
|
|
499
|
+
<div class="flex min-h-0 flex-col lg:h-full">
|
|
500
|
+
{#snippet galleryTabs()}
|
|
501
|
+
<Tabs.Root value={tab} onValueChange={(value) => selectTab(value as 'photos' | 'favorites' | 'albums' | 'shared')}>
|
|
502
|
+
<Tabs.List class="stor-page-tabs stor-primary-tabs flex max-w-full items-center" aria-label="Gallery sections" {@attach tabIndicator}>
|
|
503
|
+
<Tabs.Trigger value="photos" class="stor-page-tab">Photos</Tabs.Trigger>
|
|
504
|
+
<Tabs.Trigger value="favorites" class="stor-page-tab">Favorites</Tabs.Trigger>
|
|
505
|
+
<Tabs.Trigger value="albums" class="stor-page-tab">Albums</Tabs.Trigger>
|
|
506
|
+
<Tabs.Trigger value="shared" class="stor-page-tab">Shared</Tabs.Trigger>
|
|
507
|
+
</Tabs.List>
|
|
508
|
+
</Tabs.Root>
|
|
509
|
+
{/snippet}
|
|
510
|
+
<PageHeader title="Gallery" tabs={galleryTabs} class="shrink-0 px-3 pt-4 pb-2">
|
|
511
|
+
<Button variant="ghost" class="hidden sm:inline-flex" onclick={() => backupOpen = true}><IconArchive class="h-4 w-4" /> Backup</Button>
|
|
512
|
+
<CommandOnly>
|
|
513
|
+
{#if tab === 'albums'}
|
|
514
|
+
<Button class="hidden lg:inline-flex" onclick={() => createAlbumOpen = true}><IconPlus class="h-4 w-4" /> New album</Button>
|
|
515
|
+
{:else if tab === 'photos' || tab === 'favorites'}
|
|
516
|
+
<Button variant="secondary" class="hidden lg:inline-flex" aria-pressed={selectMode} onclick={() => selectMode ? cancelSelection() : beginSelection()}>{selectMode ? 'Cancel selection' : 'Select'}</Button>
|
|
517
|
+
<Button class="hidden lg:inline-flex" onclick={() => fileInput?.click()} disabled={busy}><IconPlus class="h-4 w-4" /> Add photos</Button>
|
|
518
|
+
{/if}
|
|
519
|
+
</CommandOnly>
|
|
520
|
+
</PageHeader>
|
|
521
|
+
<input bind:this={fileInput} type="file" multiple accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif" class="sr-only" aria-label="Choose photos" onchange={(event) => void addPhotos(event.currentTarget.files)} />
|
|
522
|
+
{#if importError}<p role="alert" class="mx-3 mb-3 text-sm text-[var(--stor-negative)]">{importError}</p>{/if}
|
|
523
|
+
{#if tab === 'albums'}
|
|
524
|
+
<div class={albumId ? 'gallery-view flex min-h-0 flex-col gap-3 px-3 pb-3 lg:flex-1' : 'min-h-0 px-3 pb-3 lg:flex-1 lg:overflow-y-auto'}>
|
|
525
|
+
{#if !albumId}<Button variant="ghost" class="mb-3 sm:hidden" onclick={() => backupOpen = true}><IconArchive class="h-4 w-4" /> Backup</Button>{/if}
|
|
526
|
+
{#if data.tenantId && data.logId}
|
|
527
|
+
<AlbumSection
|
|
528
|
+
bind:this={albumSection}
|
|
529
|
+
tenantId={data.tenantId}
|
|
530
|
+
logId={data.logId}
|
|
531
|
+
revision={resource.revision}
|
|
532
|
+
albumId={albumId}
|
|
533
|
+
density={density}
|
|
534
|
+
onOpenAlbum={openAlbum}
|
|
535
|
+
onOpenPhoto={openPhoto}
|
|
536
|
+
openCreate={createAlbumOpen}
|
|
537
|
+
onCloseCreate={() => createAlbumOpen = false}
|
|
538
|
+
onCreate={() => createAlbumOpen = true}
|
|
539
|
+
onPublish={(album) => publishSource = {
|
|
540
|
+
albumId: album.id,
|
|
541
|
+
title: album.title,
|
|
542
|
+
description: album.description
|
|
543
|
+
}}
|
|
544
|
+
/>
|
|
545
|
+
{/if}
|
|
546
|
+
</div>
|
|
547
|
+
{:else if tab === 'shared'}
|
|
548
|
+
<div class="min-h-0 px-3 pb-3 lg:flex-1 lg:overflow-y-auto">
|
|
549
|
+
<Button variant="ghost" class="mb-3 sm:hidden" onclick={() => backupOpen = true}><IconArchive class="h-4 w-4" /> Backup</Button>
|
|
550
|
+
{#if data.tenantId && data.logId}<PublicationManager tenantId={data.tenantId} logId={data.logId} revision={resource.revision} source={publishSource} showList={true} onCloseSource={() => publishSource = null} />{/if}
|
|
551
|
+
</div>
|
|
552
|
+
{:else}
|
|
553
|
+
<div class="gallery-view flex min-h-0 flex-col gap-3 px-3 pb-3 lg:flex-1">
|
|
554
|
+
<div class="stor-panel flex shrink-0 items-center gap-1 p-1.5 sm:p-2">
|
|
555
|
+
<label class="flex min-w-0 flex-1 items-center gap-2 px-2 text-[var(--stor-muted-text)]">
|
|
556
|
+
<IconSearch class="h-5 w-5" />
|
|
557
|
+
<input type="search" value={query} oninput={(event) => setQuery(event.currentTarget.value)} placeholder="Search photos" aria-label="Search photos" class="h-11 min-w-0 flex-1 bg-transparent text-sm text-[var(--stor-ink)] outline-none placeholder:text-[var(--stor-muted-text)] sm:h-9" />
|
|
558
|
+
</label>
|
|
559
|
+
|
|
560
|
+
<div
|
|
561
|
+
class="hidden shrink-0 items-center gap-0.5 rounded-xl bg-[var(--stor-surface-muted)] p-0.5 sm:flex"
|
|
562
|
+
role="group"
|
|
563
|
+
aria-label="Photo size"
|
|
564
|
+
>
|
|
565
|
+
<Button
|
|
566
|
+
variant="ghost"
|
|
567
|
+
size="icon"
|
|
568
|
+
aria-label="Smaller photos"
|
|
569
|
+
disabled={density === 0}
|
|
570
|
+
onclick={() => changeDensity(density - 1 as GalleryDensity)}
|
|
571
|
+
><IconMinus class="h-4 w-4" /></Button>
|
|
572
|
+
|
|
573
|
+
<Button
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="icon"
|
|
576
|
+
aria-label="Larger photos"
|
|
577
|
+
disabled={density === 2}
|
|
578
|
+
onclick={() => changeDensity(density + 1 as GalleryDensity)}
|
|
579
|
+
><IconPlus class="h-4 w-4" /></Button>
|
|
580
|
+
</div>
|
|
581
|
+
{#if source && source.summary.count > 0}
|
|
582
|
+
<CommandOnly><Button variant="ghost" class="lg:hidden" aria-pressed={selectMode} onclick={() => selectMode ? cancelSelection() : beginSelection()}>{selectMode ? 'Cancel' : 'Select'}</Button></CommandOnly>
|
|
583
|
+
{/if}
|
|
584
|
+
<DropdownMenu.Root>
|
|
585
|
+
<DropdownMenu.Trigger class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[var(--stor-muted-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] sm:hidden" aria-label="Gallery actions"><IconDotsVertical class="h-5 w-5" /></DropdownMenu.Trigger>
|
|
586
|
+
<DropdownMenu.Portal>
|
|
587
|
+
<DropdownMenu.Content side="bottom" align="end" sideOffset={4} class="stor-menu z-[var(--stor-layer-menu)] w-44 py-1">
|
|
588
|
+
<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={() => backupOpen = true}><IconArchive class="h-4 w-4" /> Backup</DropdownMenu.Item>
|
|
589
|
+
</DropdownMenu.Content>
|
|
590
|
+
</DropdownMenu.Portal>
|
|
591
|
+
</DropdownMenu.Root>
|
|
592
|
+
</div>
|
|
593
|
+
{#if !source}
|
|
594
|
+
<section class="stor-panel p-4 lg:flex-1" aria-label="Photos"><DataPlaceholder rows={6} label="Loading photos" /></section>
|
|
595
|
+
{:else if source.summary.count === 0}
|
|
596
|
+
<EmptyState
|
|
597
|
+
icon={IconImage}
|
|
598
|
+
title={source.filter.query ? 'No photos match' : tab === 'favorites' ? 'No favorites yet' : 'Your gallery starts here'}
|
|
599
|
+
description={source.filter.query ? 'Search looks at captions and file names.' : tab === 'favorites' ? 'Mark a photo as a favorite in the viewer.' : 'Add photos to keep them in your private library.'}
|
|
600
|
+
>
|
|
601
|
+
{#if tab === 'photos' && !source.filter.query}<CommandOnly><Button onclick={() => fileInput?.click()}><IconPlus class="h-4 w-4" /> Add photos</Button></CommandOnly>{/if}
|
|
602
|
+
</EmptyState>
|
|
603
|
+
{:else}
|
|
604
|
+
<section class="stor-panel relative min-h-0 flex-1" aria-label="Photos">
|
|
605
|
+
<GalleryTimeline
|
|
606
|
+
bind:this={timeline}
|
|
607
|
+
source={source}
|
|
608
|
+
tenantId={data.tenantId}
|
|
609
|
+
logId={data.logId}
|
|
610
|
+
revision={resource.revision}
|
|
611
|
+
density={density}
|
|
612
|
+
opening={opening}
|
|
613
|
+
beginning={beginning}
|
|
614
|
+
selecting={selectMode}
|
|
615
|
+
selectedIds={selectedPhotoIds}
|
|
616
|
+
onOpen={(id) => selectMode ? toggleSelectedPhoto(id) : openPhoto(id)}
|
|
617
|
+
onSelect={selectPhoto}
|
|
618
|
+
onSelectMonth={(month, select) => void selectMonth(month, select)}
|
|
619
|
+
onOpenAlbum={openAlbumFromTimeline}
|
|
620
|
+
onMonthChange={followMonth}
|
|
621
|
+
/>
|
|
622
|
+
{#if selectMode}
|
|
623
|
+
<div class="absolute inset-x-2 bottom-2 z-10 flex flex-wrap items-center gap-2 rounded-2xl bg-[var(--stor-surface-raised)] p-1.5 pl-4 text-sm text-[var(--stor-ink)] shadow-[var(--stor-elevation-3)] sm:inset-x-auto sm:left-1/2 sm:-translate-x-1/2 sm:flex-nowrap sm:rounded-full">
|
|
624
|
+
<span role="status" class="mr-auto whitespace-nowrap font-semibold">{selectedPhotoIds.size} selected</span>
|
|
625
|
+
<CommandOnly>
|
|
626
|
+
<Button variant="secondary" disabled={selectedPhotoIds.size === 0} onclick={() => void openAddToAlbum()}>Add to album</Button>
|
|
627
|
+
<Button disabled={selectedPhotoIds.size === 0} onclick={reviewSelected}>Prepare public gallery</Button>
|
|
628
|
+
</CommandOnly>
|
|
629
|
+
<Button variant="ghost" onclick={cancelSelection}>Cancel</Button>
|
|
630
|
+
</div>
|
|
631
|
+
{/if}
|
|
632
|
+
</section>
|
|
633
|
+
{/if}
|
|
634
|
+
</div>
|
|
635
|
+
{/if}
|
|
636
|
+
</div>
|
|
637
|
+
|
|
638
|
+
<MobilePageAction hidden={selectMode}>
|
|
639
|
+
{#if tab === 'albums'}
|
|
640
|
+
<Button onclick={() => runCommandAction(() => createAlbumOpen = true)}><IconPlus class="h-4 w-4" /> New album</Button>
|
|
641
|
+
{:else if tab === 'photos' || tab === 'favorites'}
|
|
642
|
+
<Button onclick={() => runCommandAction(() => fileInput?.click())} disabled={busy}><IconPlus class="h-4 w-4" /> Add photos</Button>
|
|
643
|
+
{/if}
|
|
644
|
+
</MobilePageAction>
|
|
645
|
+
|
|
646
|
+
{#if tab !== 'shared' && data.tenantId && data.logId}
|
|
647
|
+
<PublicationManager tenantId={data.tenantId} logId={data.logId} revision={resource.revision} source={publishSource} showList={false} onCloseSource={() => publishSource = null} />
|
|
648
|
+
{/if}
|
|
649
|
+
|
|
650
|
+
{#if selected && data.tenantId && data.logId}
|
|
651
|
+
<PhotoViewer
|
|
652
|
+
photo={selected}
|
|
653
|
+
tenantId={data.tenantId}
|
|
654
|
+
logId={data.logId}
|
|
655
|
+
filter={viewerFilter}
|
|
656
|
+
revision={resource.revision}
|
|
657
|
+
onClose={closePhoto}
|
|
658
|
+
onNavigate={(id) => openPhoto(id, viewerFilter)}
|
|
659
|
+
onFavorite={() => void toggleFavorite()}
|
|
660
|
+
onEdit={editPhoto}
|
|
661
|
+
onRemove={() => removeOpen = true}
|
|
662
|
+
onOpenAlbum={(id) => { closePhoto(); openAlbumFromTimeline(id); }}
|
|
663
|
+
/>
|
|
664
|
+
{/if}
|
|
665
|
+
|
|
666
|
+
<CommandModal show={editOpen} title="Edit photo" onClose={closeEdit}>
|
|
667
|
+
<form class="space-y-4" onsubmit={(event) => { event.preventDefault(); void savePhoto(); }}>
|
|
668
|
+
<label class="block text-sm font-medium text-[var(--stor-ink)]">Caption<Textarea bind:value={captionDraft} maxlength={500} class="mt-1" /></label>
|
|
669
|
+
{#if editError}<p role="alert" class="text-sm text-[var(--stor-negative)]">{editError}</p>{/if}
|
|
670
|
+
<div class="flex justify-end"><Button type="submit" disabled={busy}>{busy ? 'Saving…' : 'Save caption'}</Button></div>
|
|
671
|
+
</form>
|
|
672
|
+
</CommandModal>
|
|
673
|
+
|
|
674
|
+
<CommandConfirmDialog
|
|
675
|
+
show={removeOpen}
|
|
676
|
+
title="Remove photo?"
|
|
677
|
+
message="The photo leaves this log and its albums. Its original remains available to accepted history and other logs."
|
|
678
|
+
confirmLabel="Remove photo"
|
|
679
|
+
onConfirm={() => void removePhoto()}
|
|
680
|
+
onCancel={() => removeOpen = false}
|
|
681
|
+
/>
|
|
682
|
+
|
|
683
|
+
<CommandModal
|
|
684
|
+
show={addAlbumOpen}
|
|
685
|
+
title="Add photos to album"
|
|
686
|
+
onClose={() => {
|
|
687
|
+
if (!selectionBusy) addAlbumOpen = false;
|
|
688
|
+
}}
|
|
689
|
+
>
|
|
690
|
+
<form
|
|
691
|
+
class="space-y-4"
|
|
692
|
+
onsubmit={(event) => {
|
|
693
|
+
event.preventDefault();
|
|
694
|
+
void addSelectionToAlbum();
|
|
695
|
+
}}
|
|
696
|
+
>
|
|
697
|
+
<p class="text-sm text-[var(--stor-muted-text)]">{selectedPhotoIds.size} {selectedPhotoIds.size === 1 ? 'photo' : 'photos'} selected</p>
|
|
698
|
+
{#if addAlbumLoading}
|
|
699
|
+
<p role="status" class="text-sm text-[var(--stor-muted-text)]">Loading albums…</p>
|
|
700
|
+
{:else if albumChoices.length > 0}
|
|
701
|
+
<Select value={selectedAlbumId} options={albumChoices.map((album) => ({ value: album.id, label: album.title }))} ariaLabel="Album" placeholder="Choose an album" disabled={selectionBusy} onValueChange={(value) => { selectedAlbumId = value; pendingAlbumEvents = null; pendingAlbumSubmissionId = ''; }} />
|
|
702
|
+
{:else if !selectionError}
|
|
703
|
+
<p class="text-sm text-[var(--stor-muted-text)]">No albums yet. Create one in Albums first.</p>
|
|
704
|
+
{/if}
|
|
705
|
+
{#if selectionError}<p role="alert" class="text-sm text-[var(--stor-negative)]">{selectionError}</p>{/if}
|
|
706
|
+
<div class="flex justify-end"><Button type="submit" disabled={selectionBusy || addAlbumLoading || !selectedAlbumId}>{selectionBusy ? 'Adding…' : 'Add to album'}</Button></div>
|
|
707
|
+
</form>
|
|
708
|
+
</CommandModal>
|
|
709
|
+
|
|
710
|
+
<CommandModal show={backupOpen} title="Gallery backup" onClose={() => { if (!backupBusy) backupOpen = false; }}>
|
|
711
|
+
<div class="space-y-4 text-sm text-[var(--stor-ink)]">
|
|
712
|
+
<p>Save your original photos and albums to a private archive. It includes filenames and camera location data when present.</p>
|
|
713
|
+
<Button variant="secondary" disabled={backupBusy} onclick={() => void downloadBackup()}><IconArchive class="h-4 w-4" /> Download backup</Button>
|
|
714
|
+
<div class="border-t border-[var(--stor-border)] pt-4">
|
|
715
|
+
<label class="block font-medium" for="gallery-restore-file">Restore into this log</label>
|
|
716
|
+
<input id="gallery-restore-file" type="file" accept=".tar,application/x-tar" class="mt-2 block w-full text-sm" disabled={backupBusy} onchange={(event) => void previewBackup(event.currentTarget.files?.[0])} />
|
|
717
|
+
{#if restoreCounts}
|
|
718
|
+
<p class="mt-2 text-[var(--stor-muted-text)]">{restoreCounts.photos} {restoreCounts.photos === 1 ? 'photo' : 'photos'} · {restoreCounts.albums} {restoreCounts.albums === 1 ? 'album' : 'albums'}. Restored items receive new IDs.</p>
|
|
719
|
+
<div class="mt-3"><Button disabled={backupBusy || !restoreCounts.photos} onclick={() => runCommandAction(() => void restoreBackup())}>Restore backup</Button></div>
|
|
720
|
+
{/if}
|
|
721
|
+
</div>
|
|
722
|
+
{#if backupStatus}<p role="status" class="text-[var(--stor-muted-text)]">{backupStatus}</p>{/if}
|
|
723
|
+
{#if backupError}<p role="alert" class="text-[var(--stor-negative)]">{backupError}</p>{/if}
|
|
724
|
+
</div>
|
|
725
|
+
</CommandModal>
|
|
726
|
+
|
|
727
|
+
<style>
|
|
728
|
+
/* On smaller screens the page scrolls: the timeline fills what the header and navigation leave. */
|
|
729
|
+
.gallery-view {
|
|
730
|
+
height: calc(100dvh - var(--stor-mobile-header-height) - var(--stor-mobile-bottom-inset) - var(--stor-mobile-nav-height) - var(--stor-mobile-stack-gap) - var(--stor-mobile-action-height) - 24px);
|
|
731
|
+
min-height: 24rem;
|
|
732
|
+
flex-shrink: 0;
|
|
733
|
+
}
|
|
734
|
+
@media (min-width: 1024px) {
|
|
735
|
+
.gallery-view { height: auto; min-height: 0; flex-shrink: 1; }
|
|
736
|
+
}
|
|
737
|
+
</style>
|