@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,294 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { GalleryFilter, GallerySummary } from '../../data/gallery.js';
|
|
3
|
+
|
|
4
|
+
/** A summary with the filter it was read under; the timeline lists exactly these blocks. */
|
|
5
|
+
export interface GalleryTimelineSource {
|
|
6
|
+
filter: GalleryFilter;
|
|
7
|
+
summary: GallerySummary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Where a timeline opens: the top of the list (newest), its bottom (oldest), or a `yyyy-MM` month. */
|
|
11
|
+
export type GalleryOpening = 'end' | 'start' | string;
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import { untrack } from 'svelte';
|
|
16
|
+
import { EndlessList, type EndlessBlock, type EndlessPosition, type EndlessTarget } from '@hearthlog/sdk/endless';
|
|
17
|
+
import { listAxis, nearestKey } from '@hearthlog/sdk/axis';
|
|
18
|
+
import { queryDatabase } from '@hearthlog/sdk/data';
|
|
19
|
+
import type { GalleryBlockSummary } from '../../data/gallery.js';
|
|
20
|
+
import type { GalleryPhoto } from '@hearthlog/sdk/data';
|
|
21
|
+
import {
|
|
22
|
+
compareGalleryBlockKeys, estimateGalleryBlock, galleryBlockKey, galleryBlockMonth, galleryBlockPart, galleryGeometry, type GalleryDensity,
|
|
23
|
+
} from '../../gallery/timeline.js';
|
|
24
|
+
import { GalleryBlocks } from './gallery-blocks.svelte.js';
|
|
25
|
+
import { formatMonthParts, formatPhotoCount } from '../../gallery/format.js';
|
|
26
|
+
import GalleryMonthBlock from './GalleryMonthBlock.svelte';
|
|
27
|
+
import GallerySpine from './view/GallerySpine.svelte';
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
source,
|
|
31
|
+
tenantId,
|
|
32
|
+
logId,
|
|
33
|
+
revision,
|
|
34
|
+
density,
|
|
35
|
+
opening = 'start',
|
|
36
|
+
chips = true,
|
|
37
|
+
showLatest = true,
|
|
38
|
+
beginning = '',
|
|
39
|
+
selecting = false,
|
|
40
|
+
selectedIds,
|
|
41
|
+
onOpen,
|
|
42
|
+
onSelect,
|
|
43
|
+
onSelectMonth,
|
|
44
|
+
onOpenAlbum,
|
|
45
|
+
onMonthChange,
|
|
46
|
+
}: {
|
|
47
|
+
source: GalleryTimelineSource;
|
|
48
|
+
tenantId: string;
|
|
49
|
+
logId: string;
|
|
50
|
+
/** Changes after a save or a sync; the blocks on screen then read again. */
|
|
51
|
+
revision: number;
|
|
52
|
+
density: GalleryDensity;
|
|
53
|
+
/** Read when the timeline opens and when its filter changes. */
|
|
54
|
+
opening?: GalleryOpening;
|
|
55
|
+
/** Month headers name the albums they hold. */
|
|
56
|
+
chips?: boolean;
|
|
57
|
+
/** A Latest button returns to the newest photo. */
|
|
58
|
+
showLatest?: boolean;
|
|
59
|
+
beginning?: string;
|
|
60
|
+
selecting?: boolean;
|
|
61
|
+
selectedIds: Set<string>;
|
|
62
|
+
onOpen: (id: string) => void;
|
|
63
|
+
onSelect: (id: string) => void;
|
|
64
|
+
onSelectMonth: (month: string, select: boolean) => void;
|
|
65
|
+
onOpenAlbum?: (id: string) => void;
|
|
66
|
+
/** The month at the top of the list, as the list scrolls. */
|
|
67
|
+
onMonthChange?: (month: string) => void;
|
|
68
|
+
} = $props();
|
|
69
|
+
|
|
70
|
+
const blocks = $derived(source.summary.blocks);
|
|
71
|
+
const keys = $derived(blocks.map((block) => block.key));
|
|
72
|
+
const axis = $derived(listAxis(keys, { descending: true, compare: compareGalleryBlockKeys }));
|
|
73
|
+
const shapes = $derived(new Map(blocks.map((block) => [block.key, block])));
|
|
74
|
+
const topKey = $derived(keys[0] ?? '');
|
|
75
|
+
const lastKey = $derived(keys[keys.length - 1] ?? '');
|
|
76
|
+
|
|
77
|
+
let root = $state<HTMLDivElement>();
|
|
78
|
+
let width = $state(0);
|
|
79
|
+
// Rows follow the width a frame later, so relaying them never feeds back into the same resize.
|
|
80
|
+
$effect(() => {
|
|
81
|
+
if (!root) return;
|
|
82
|
+
let frame = 0;
|
|
83
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
84
|
+
cancelAnimationFrame(frame);
|
|
85
|
+
frame = requestAnimationFrame(() => width = Math.round(entry.contentRect.width));
|
|
86
|
+
});
|
|
87
|
+
observer.observe(root);
|
|
88
|
+
return () => {
|
|
89
|
+
observer.disconnect();
|
|
90
|
+
cancelAnimationFrame(frame);
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const inset = $derived(width >= 640 ? { start: 24, end: 88 } : { start: 10, end: 24 });
|
|
94
|
+
const geometry = $derived(galleryGeometry(Math.max(0, width - inset.start - inset.end), density));
|
|
95
|
+
const estimate = $derived((key: string) => estimateGalleryBlock(shapes.get(key), geometry));
|
|
96
|
+
|
|
97
|
+
let list = $state<ReturnType<typeof EndlessList>>();
|
|
98
|
+
let position = $state<EndlessPosition | null>(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The list keeps its keys while it is open, so it opens again when they
|
|
102
|
+
* change: at the filter's opening place for a new filter, or where it was
|
|
103
|
+
* when a month appears or leaves in the middle. Keys are newest first, so
|
|
104
|
+
* a month added or removed at the top keeps it open. Blocks are read again
|
|
105
|
+
* only for a new filter.
|
|
106
|
+
*/
|
|
107
|
+
const tracked = { keys: [] as string[], filter: '', filterEpoch: 0, listEpoch: 0, opening: { key: '' } as EndlessTarget };
|
|
108
|
+
const epochs = $derived.by(() => {
|
|
109
|
+
const filter = JSON.stringify(source.filter);
|
|
110
|
+
const previous = tracked.keys;
|
|
111
|
+
const common = Math.min(previous.length, keys.length);
|
|
112
|
+
let kept = true;
|
|
113
|
+
for (let index = 1; index <= common; index++) {
|
|
114
|
+
if (previous[previous.length - index] !== keys[keys.length - index]) { kept = false; break; }
|
|
115
|
+
}
|
|
116
|
+
if (filter !== tracked.filter || !kept || previous.length === 0) {
|
|
117
|
+
const was = untrack(() => position);
|
|
118
|
+
if (filter !== tracked.filter || !was) {
|
|
119
|
+
tracked.filterEpoch += filter !== tracked.filter ? 1 : 0;
|
|
120
|
+
const wanted = untrack(() => opening);
|
|
121
|
+
tracked.opening = wanted === 'start' ? { key: keys[0] ?? '' }
|
|
122
|
+
: wanted === 'end' ? { key: keys[keys.length - 1] ?? '' }
|
|
123
|
+
: { key: nearestKey(axis, galleryBlockKey(wanted)) };
|
|
124
|
+
} else {
|
|
125
|
+
tracked.opening = was.at === previous[previous.length - 1] ? { key: keys[keys.length - 1] ?? '' } : { key: nearestKey(axis, was.key) };
|
|
126
|
+
}
|
|
127
|
+
tracked.listEpoch += 1;
|
|
128
|
+
}
|
|
129
|
+
tracked.keys = keys;
|
|
130
|
+
tracked.filter = filter;
|
|
131
|
+
return { filter: tracked.filterEpoch, list: tracked.listEpoch };
|
|
132
|
+
});
|
|
133
|
+
const store = $derived.by(() => {
|
|
134
|
+
void epochs.filter;
|
|
135
|
+
return untrack(() => new GalleryBlocks(source.filter, chips));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const home = $derived(showLatest && !selecting && topKey ? { key: topKey, at: topKey, label: 'Latest' } : undefined);
|
|
139
|
+
|
|
140
|
+
/** The block at the top edge names the month; the last block on screen closes the spine's band. */
|
|
141
|
+
function pick(onScreen: EndlessBlock[]): EndlessPosition {
|
|
142
|
+
return { key: onScreen[0].key, at: onScreen[onScreen.length - 1].key };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function follow(next: EndlessPosition): void {
|
|
146
|
+
position = next;
|
|
147
|
+
onMonthChange?.(galleryBlockMonth(next.key));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A big month's later blocks have no header of their own; this one stands in while they pass. */
|
|
151
|
+
const continued = $derived(position && galleryBlockPart(position.key) > 0 ? shapes.get(position.key) : undefined);
|
|
152
|
+
|
|
153
|
+
function monthSelected(block: GalleryBlockSummary): boolean {
|
|
154
|
+
if (!selecting || selectedIds.size === 0) return false;
|
|
155
|
+
for (let part = 0; part < block.parts; part++) {
|
|
156
|
+
const data = store.get(galleryBlockKey(block.month, part));
|
|
157
|
+
if (!data || !data.photos.every((photo) => selectedIds.has(photo.id))) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Phones show the spine while the list moves.
|
|
163
|
+
let scrolling = $state(false);
|
|
164
|
+
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
165
|
+
function moved(): void {
|
|
166
|
+
scrolling = true;
|
|
167
|
+
clearTimeout(scrollTimer);
|
|
168
|
+
scrollTimer = setTimeout(() => scrolling = false, 1400);
|
|
169
|
+
}
|
|
170
|
+
$effect(() => () => clearTimeout(scrollTimer));
|
|
171
|
+
|
|
172
|
+
export function scrollToMonth(month: string): void {
|
|
173
|
+
if (keys.length) list?.scrollTo({ key: nearestKey(axis, galleryBlockKey(month)) });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function scrollToLatest(): void {
|
|
177
|
+
if (topKey) list?.scrollTo({ key: topKey, at: topKey });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Brings a photo's tile into view, loading its block when it is far away, and focuses it. */
|
|
181
|
+
export async function revealPhoto(photo: GalleryPhoto): Promise<void> {
|
|
182
|
+
const selector = `[data-photo-id="${CSS.escape(photo.id)}"]`;
|
|
183
|
+
const scroller = root?.querySelector<HTMLElement>('[data-gallery-scroll]');
|
|
184
|
+
const tile = root?.querySelector<HTMLElement>(selector);
|
|
185
|
+
if (tile && scroller) {
|
|
186
|
+
const box = scroller.getBoundingClientRect();
|
|
187
|
+
const rect = tile.getBoundingClientRect();
|
|
188
|
+
if (rect.top >= box.top + geometry.header && rect.bottom <= box.bottom) {
|
|
189
|
+
tile.focus({ preventScroll: true });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const key = await queryDatabase((repositories) => repositories.gallery.photoBlock(photo, source.filter));
|
|
194
|
+
list?.scrollTo({ key, at: key, find: (block) => block.querySelector<HTMLElement>(selector), ratio: 0.35 });
|
|
195
|
+
let frames = 90;
|
|
196
|
+
const focus = () => {
|
|
197
|
+
const shown = root?.querySelector<HTMLElement>(selector);
|
|
198
|
+
if (shown) shown.focus({ preventScroll: true });
|
|
199
|
+
else if (--frames > 0) requestAnimationFrame(focus);
|
|
200
|
+
};
|
|
201
|
+
requestAnimationFrame(focus);
|
|
202
|
+
}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<div
|
|
206
|
+
bind:this={root}
|
|
207
|
+
class="gallery-timeline"
|
|
208
|
+
style:--gallery-inset-start={`${inset.start}px`}
|
|
209
|
+
style:--gallery-inset-end={`${inset.end}px`}
|
|
210
|
+
onscrollcapture={moved}
|
|
211
|
+
>
|
|
212
|
+
{#if keys.length && width > 0}
|
|
213
|
+
{#key epochs.list}
|
|
214
|
+
<EndlessList
|
|
215
|
+
bind:this={list}
|
|
216
|
+
{axis}
|
|
217
|
+
{store}
|
|
218
|
+
opening={tracked.opening}
|
|
219
|
+
{estimate}
|
|
220
|
+
{revision}
|
|
221
|
+
{home}
|
|
222
|
+
{pick}
|
|
223
|
+
onVisibleChange={follow}
|
|
224
|
+
class="gallery-scroller"
|
|
225
|
+
data-gallery-scroll
|
|
226
|
+
aria-label="Photos by month"
|
|
227
|
+
>
|
|
228
|
+
{#snippet item(key, data)}
|
|
229
|
+
{@const block = shapes.get(key)}
|
|
230
|
+
{#if block}
|
|
231
|
+
<GalleryMonthBlock
|
|
232
|
+
{block}
|
|
233
|
+
{data}
|
|
234
|
+
{geometry}
|
|
235
|
+
{tenantId}
|
|
236
|
+
{logId}
|
|
237
|
+
beginning={key === keys[0] ? beginning : ''}
|
|
238
|
+
last={key === lastKey}
|
|
239
|
+
{selecting}
|
|
240
|
+
{selectedIds}
|
|
241
|
+
monthSelected={block.part === 0 && monthSelected(block)}
|
|
242
|
+
{onOpen}
|
|
243
|
+
{onSelect}
|
|
244
|
+
{onSelectMonth}
|
|
245
|
+
onOpenAlbum={chips ? onOpenAlbum : undefined}
|
|
246
|
+
/>
|
|
247
|
+
{/if}
|
|
248
|
+
{/snippet}
|
|
249
|
+
</EndlessList>
|
|
250
|
+
{/key}
|
|
251
|
+
{#if continued}
|
|
252
|
+
{@const month = formatMonthParts(continued.month)}
|
|
253
|
+
<div class="gallery-continued" aria-hidden="true">
|
|
254
|
+
<span class="gallery-continued-title stor-serif-title">{month.name} <span>{month.year}</span></span>
|
|
255
|
+
<span class="gallery-continued-count">{formatPhotoCount(continued.month_photos)}</span>
|
|
256
|
+
</div>
|
|
257
|
+
{/if}
|
|
258
|
+
<GallerySpine
|
|
259
|
+
months={source.summary.months}
|
|
260
|
+
first={position ? galleryBlockMonth(position.key) : ''}
|
|
261
|
+
last={position ? galleryBlockMonth(position.at) : ''}
|
|
262
|
+
active={scrolling}
|
|
263
|
+
onJump={scrollToMonth}
|
|
264
|
+
onLatest={scrollToLatest}
|
|
265
|
+
/>
|
|
266
|
+
{/if}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<style>
|
|
270
|
+
.gallery-timeline { position: relative; height: 100%; min-height: 0; }
|
|
271
|
+
.gallery-timeline :global(.gallery-scroller) { scrollbar-width: none; }
|
|
272
|
+
.gallery-timeline :global(.gallery-scroller::-webkit-scrollbar) { display: none; }
|
|
273
|
+
.gallery-continued {
|
|
274
|
+
position: absolute;
|
|
275
|
+
top: 0;
|
|
276
|
+
left: 0;
|
|
277
|
+
right: 0;
|
|
278
|
+
z-index: 2;
|
|
279
|
+
display: flex;
|
|
280
|
+
min-height: 52px;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 10px;
|
|
283
|
+
padding-inline: var(--gallery-inset-start) var(--gallery-inset-end);
|
|
284
|
+
background: var(--stor-surface);
|
|
285
|
+
pointer-events: none;
|
|
286
|
+
}
|
|
287
|
+
.gallery-continued-title { font-size: 20px; line-height: 1.1; color: var(--stor-ink); }
|
|
288
|
+
.gallery-continued-title span { font-weight: 400; color: var(--stor-muted-text); }
|
|
289
|
+
.gallery-continued-count { margin-left: auto; color: var(--stor-muted-text); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
290
|
+
@media (min-width: 640px) {
|
|
291
|
+
.gallery-continued { min-height: 60px; }
|
|
292
|
+
.gallery-continued-title { font-size: 23px; }
|
|
293
|
+
}
|
|
294
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { GalleryPhoto } from '@hearthlog/sdk/data';
|
|
3
|
+
import { galleryRendition } from '../../gallery/file-store.js';
|
|
4
|
+
|
|
5
|
+
let { photo, tenantId, logId }: { photo: GalleryPhoto; tenantId: string; logId: string } = $props();
|
|
6
|
+
|
|
7
|
+
let url = $state('');
|
|
8
|
+
|
|
9
|
+
$effect(() => {
|
|
10
|
+
if (!tenantId || !logId) return;
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
let objectUrl = '';
|
|
13
|
+
void galleryRendition({ tenantId, logId, photoId: photo.id, fileVersionId: photo.file_version_id }, 'thumbnail').then((blob) => {
|
|
14
|
+
if (cancelled) return;
|
|
15
|
+
objectUrl = URL.createObjectURL(blob);
|
|
16
|
+
url = objectUrl;
|
|
17
|
+
}).catch(() => undefined);
|
|
18
|
+
return () => {
|
|
19
|
+
cancelled = true;
|
|
20
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
21
|
+
url = '';
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if url}<img src={url} alt="" draggable="false" class="h-full w-full object-cover" />{/if}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import type { GalleryPhoto } from '@hearthlog/sdk/data';
|
|
4
|
+
import { formatTileDay } from '../../gallery/format.js';
|
|
5
|
+
import GalleryTile from './view/GalleryTile.svelte';
|
|
6
|
+
import {
|
|
7
|
+
galleryRendition,
|
|
8
|
+
galleryUploadState,
|
|
9
|
+
type GalleryFileScope,
|
|
10
|
+
} from '../../gallery/file-store.js';
|
|
11
|
+
import IconImage from '~icons/mdi/image-outline';
|
|
12
|
+
import IconCloudUpload from '~icons/mdi/cloud-upload-outline';
|
|
13
|
+
import IconHeart from '~icons/mdi/heart';
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
photo,
|
|
17
|
+
tenantId,
|
|
18
|
+
logId,
|
|
19
|
+
width,
|
|
20
|
+
height,
|
|
21
|
+
onOpen,
|
|
22
|
+
onSelect,
|
|
23
|
+
selected = false,
|
|
24
|
+
selectable = false,
|
|
25
|
+
}: {
|
|
26
|
+
photo: GalleryPhoto;
|
|
27
|
+
tenantId: string;
|
|
28
|
+
logId: string;
|
|
29
|
+
/** The tile's size in its row. */
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
onOpen: () => void;
|
|
33
|
+
/** The check in the corner selects the photo, and starts selecting. */
|
|
34
|
+
onSelect: () => void;
|
|
35
|
+
selected?: boolean;
|
|
36
|
+
selectable?: boolean;
|
|
37
|
+
} = $props();
|
|
38
|
+
|
|
39
|
+
let target = $state<HTMLElement>();
|
|
40
|
+
let visible = $state(false);
|
|
41
|
+
let url = $state('');
|
|
42
|
+
let status = $state<'pending' | 'needs_attention' | 'remote' | 'unavailable'>('remote');
|
|
43
|
+
let transferRevision = $state(0);
|
|
44
|
+
let offline = $state(false);
|
|
45
|
+
const scope = $derived<GalleryFileScope>({ tenantId, logId, photoId: photo.id, fileVersionId: photo.file_version_id });
|
|
46
|
+
const statusId = $derived(`gallery-status-${photo.id}`);
|
|
47
|
+
|
|
48
|
+
onMount(() => {
|
|
49
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
50
|
+
if (entry.isIntersecting) { visible = true; observer.disconnect(); }
|
|
51
|
+
}, { rootMargin: '400px' });
|
|
52
|
+
if (target) observer.observe(target);
|
|
53
|
+
const transferred = (event: Event) => {
|
|
54
|
+
if ((event as CustomEvent<string>).detail === photo.id) transferRevision += 1;
|
|
55
|
+
};
|
|
56
|
+
// Another device may finish uploading after this tile first asked.
|
|
57
|
+
const retry = () => {
|
|
58
|
+
if (status === 'unavailable') transferRevision += 1;
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener('stor:gallery-transfer', transferred);
|
|
61
|
+
window.addEventListener('online', retry);
|
|
62
|
+
window.addEventListener('focus', retry);
|
|
63
|
+
return () => {
|
|
64
|
+
observer.disconnect();
|
|
65
|
+
window.removeEventListener('stor:gallery-transfer', transferred);
|
|
66
|
+
window.removeEventListener('online', retry);
|
|
67
|
+
window.removeEventListener('focus', retry);
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
$effect(() => {
|
|
72
|
+
if (!visible || !scope.tenantId || !scope.logId) return;
|
|
73
|
+
void transferRevision;
|
|
74
|
+
let cancelled = false;
|
|
75
|
+
let objectUrl = '';
|
|
76
|
+
void Promise.all([galleryUploadState(scope), galleryRendition(scope, 'thumbnail')]).then(([upload, blob]) => {
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
objectUrl = URL.createObjectURL(blob);
|
|
79
|
+
url = objectUrl;
|
|
80
|
+
status = upload?.state ?? 'remote';
|
|
81
|
+
}).catch(() => {
|
|
82
|
+
if (!cancelled) {
|
|
83
|
+
status = 'unavailable';
|
|
84
|
+
offline = !navigator.onLine;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true;
|
|
89
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
90
|
+
url = '';
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<GalleryTile
|
|
96
|
+
bind:element={target}
|
|
97
|
+
data-photo-id={photo.id}
|
|
98
|
+
src={url}
|
|
99
|
+
alt={photo.caption || photo.filename}
|
|
100
|
+
label={`${selectable ? 'Select' : 'Open'} ${photo.filename}`}
|
|
101
|
+
size={{ width, height }}
|
|
102
|
+
day={formatTileDay(photo.timeline_day)}
|
|
103
|
+
checkable
|
|
104
|
+
{selectable}
|
|
105
|
+
{selected}
|
|
106
|
+
aria-describedby={status === 'pending' || status === 'needs_attention' || status === 'unavailable' ? statusId : undefined}
|
|
107
|
+
onOpen={() => onOpen()}
|
|
108
|
+
{onSelect}
|
|
109
|
+
>
|
|
110
|
+
{#snippet overlay()}
|
|
111
|
+
{#if !url && status === 'unavailable'}<span class="gallery-missing"><IconImage /></span>{/if}
|
|
112
|
+
{#if photo.favorite}<span class="gallery-favorite" aria-hidden="true"><IconHeart /></span>{/if}
|
|
113
|
+
{#if status === 'pending'}
|
|
114
|
+
<span id={statusId} title="Saved on this device" class="gallery-badge">
|
|
115
|
+
<IconCloudUpload /><span class="sr-only">Saved on this device</span><span aria-hidden="true" class="gallery-badge-text">Saved on this device</span>
|
|
116
|
+
</span>
|
|
117
|
+
{:else if status === 'needs_attention' || status === 'unavailable'}
|
|
118
|
+
<span id={statusId} class="gallery-badge">
|
|
119
|
+
{status === 'needs_attention' ? 'Needs attention' : offline ? 'Available when online' : 'Not uploaded yet'}
|
|
120
|
+
</span>
|
|
121
|
+
{/if}
|
|
122
|
+
{/snippet}
|
|
123
|
+
</GalleryTile>
|
|
124
|
+
|
|
125
|
+
<style>
|
|
126
|
+
.gallery-missing { position: absolute; inset: 0; display: grid; place-items: center; color: var(--stor-muted-text); }
|
|
127
|
+
.gallery-missing :global(svg) { width: 24px; height: 24px; }
|
|
128
|
+
.gallery-favorite {
|
|
129
|
+
position: absolute;
|
|
130
|
+
right: 7px;
|
|
131
|
+
bottom: 6px;
|
|
132
|
+
z-index: 1;
|
|
133
|
+
color: #fff;
|
|
134
|
+
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.45));
|
|
135
|
+
}
|
|
136
|
+
.gallery-favorite :global(svg) { display: block; width: 16px; height: 16px; }
|
|
137
|
+
.gallery-badge {
|
|
138
|
+
position: absolute;
|
|
139
|
+
bottom: 6px;
|
|
140
|
+
left: 6px;
|
|
141
|
+
z-index: 1;
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
max-width: calc(100% - 12px);
|
|
144
|
+
height: 24px;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 4px;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
border-radius: 999px;
|
|
149
|
+
background: var(--stor-surface-raised);
|
|
150
|
+
padding: 0 7px;
|
|
151
|
+
box-shadow: var(--stor-shadow-sm);
|
|
152
|
+
color: var(--stor-ink);
|
|
153
|
+
font-size: 11px;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
.gallery-badge :global(svg) { width: 16px; height: 16px; flex: none; }
|
|
158
|
+
.gallery-badge-text { display: none; }
|
|
159
|
+
@media (hover: hover) {
|
|
160
|
+
:global(.gallery-tile:hover) .gallery-badge-text { display: inline; }
|
|
161
|
+
}
|
|
162
|
+
:global(.gallery-tile:focus-visible) .gallery-badge-text { display: inline; }
|
|
163
|
+
</style>
|