@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,362 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/** A neighbouring photo; with `href` it is a real link, so it works without a script. */
|
|
3
|
+
export interface ViewerStep {
|
|
4
|
+
id: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ViewerFilmItem extends ViewerStep {
|
|
9
|
+
ratio: number;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import { tick, type Snippet } from 'svelte';
|
|
16
|
+
import type { PhotoDetailRow } from '../../../gallery/format.js';
|
|
17
|
+
import { gallerySwipeDirection } from '../../../gallery/interaction.js';
|
|
18
|
+
import IconClose from '~icons/mdi/close';
|
|
19
|
+
import IconChevronLeft from '~icons/mdi/chevron-left';
|
|
20
|
+
import IconChevronRight from '~icons/mdi/chevron-right';
|
|
21
|
+
import IconInformation from '~icons/mdi/information-outline';
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
title,
|
|
25
|
+
heading,
|
|
26
|
+
subtitle = '',
|
|
27
|
+
status = '',
|
|
28
|
+
imageKey,
|
|
29
|
+
imageUrl,
|
|
30
|
+
backdropUrl = '',
|
|
31
|
+
alt,
|
|
32
|
+
caption = '',
|
|
33
|
+
message = '',
|
|
34
|
+
older = null,
|
|
35
|
+
newer = null,
|
|
36
|
+
film = [],
|
|
37
|
+
currentId,
|
|
38
|
+
thumb,
|
|
39
|
+
details = [],
|
|
40
|
+
detailsExtra,
|
|
41
|
+
detailsOpen = $bindable(false),
|
|
42
|
+
actions,
|
|
43
|
+
closeHref,
|
|
44
|
+
onClose,
|
|
45
|
+
onNavigate,
|
|
46
|
+
onKey,
|
|
47
|
+
}: {
|
|
48
|
+
title: string;
|
|
49
|
+
/** The title element, when a dialog needs its own. */
|
|
50
|
+
heading?: Snippet<[title: string]>;
|
|
51
|
+
subtitle?: string;
|
|
52
|
+
/** A state worth announcing, such as a pending upload. */
|
|
53
|
+
status?: string;
|
|
54
|
+
/** Changes with the photo, so the next one fades in. */
|
|
55
|
+
imageKey: string;
|
|
56
|
+
imageUrl: string;
|
|
57
|
+
/** A small copy of the photo that lights the stage. */
|
|
58
|
+
backdropUrl?: string;
|
|
59
|
+
alt: string;
|
|
60
|
+
caption?: string;
|
|
61
|
+
/** Shown in place of the photo while it loads or when it fails. */
|
|
62
|
+
message?: string;
|
|
63
|
+
older?: ViewerStep | null;
|
|
64
|
+
newer?: ViewerStep | null;
|
|
65
|
+
film?: ViewerFilmItem[];
|
|
66
|
+
currentId: string;
|
|
67
|
+
thumb?: Snippet<[item: ViewerFilmItem]>;
|
|
68
|
+
details?: PhotoDetailRow[];
|
|
69
|
+
detailsExtra?: Snippet;
|
|
70
|
+
detailsOpen?: boolean;
|
|
71
|
+
/** Buttons beside Details. */
|
|
72
|
+
actions?: Snippet;
|
|
73
|
+
closeHref?: string;
|
|
74
|
+
onClose: () => void;
|
|
75
|
+
onNavigate: (id: string) => void;
|
|
76
|
+
/** Keys the frame does not handle itself. */
|
|
77
|
+
onKey?: (event: KeyboardEvent) => void;
|
|
78
|
+
} = $props();
|
|
79
|
+
|
|
80
|
+
const detailsId = $props.id();
|
|
81
|
+
const hasDetails = $derived(details.length > 0 || Boolean(detailsExtra));
|
|
82
|
+
let strip = $state<HTMLElement>();
|
|
83
|
+
let touchStart: { x: number; y: number } | null = null;
|
|
84
|
+
|
|
85
|
+
$effect(() => {
|
|
86
|
+
void film;
|
|
87
|
+
void currentId;
|
|
88
|
+
void tick().then(() => strip?.querySelector('[aria-current="true"]')?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function step(event: MouseEvent, target: ViewerStep) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onNavigate(target.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function close(event: MouseEvent) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
onClose();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleKey(event: KeyboardEvent) {
|
|
102
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
103
|
+
if ((event.target as HTMLElement | null)?.closest('input, textarea, select, [role="menu"]')) return;
|
|
104
|
+
if (event.key === 'ArrowLeft' && older) { event.preventDefault(); onNavigate(older.id); }
|
|
105
|
+
else if (event.key === 'ArrowRight' && newer) { event.preventDefault(); onNavigate(newer.id); }
|
|
106
|
+
else if (event.key === 'i' && hasDetails) detailsOpen = !detailsOpen;
|
|
107
|
+
else onKey?.(event);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function beginSwipe(event: TouchEvent) {
|
|
111
|
+
const touch = event.touches[0];
|
|
112
|
+
touchStart = event.touches.length === 1 && touch ? { x: touch.clientX, y: touch.clientY } : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function endSwipe(event: TouchEvent) {
|
|
116
|
+
const touch = event.changedTouches[0];
|
|
117
|
+
if (!touchStart || !touch) return;
|
|
118
|
+
const direction = gallerySwipeDirection(touchStart.x, touchStart.y, touch.clientX, touch.clientY);
|
|
119
|
+
touchStart = null;
|
|
120
|
+
// Swiping left moves on in time, like turning a page.
|
|
121
|
+
if (direction > 0 && newer) onNavigate(newer.id);
|
|
122
|
+
if (direction < 0 && older) onNavigate(older.id);
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<svelte:window onkeydown={handleKey} />
|
|
127
|
+
|
|
128
|
+
<div class="viewer">
|
|
129
|
+
{#if backdropUrl}<img class="viewer-backdrop" src={backdropUrl} alt="" aria-hidden="true" />{/if}
|
|
130
|
+
<header class="viewer-bar">
|
|
131
|
+
{#if closeHref}
|
|
132
|
+
<a class="viewer-button" href={closeHref} aria-label="Close photo" onclick={close}><IconClose /></a>
|
|
133
|
+
{:else}
|
|
134
|
+
<button type="button" class="viewer-button" aria-label="Close photo" onclick={close}><IconClose /></button>
|
|
135
|
+
{/if}
|
|
136
|
+
<div class="viewer-heading">
|
|
137
|
+
{#if heading}{@render heading(title)}{:else}<h2 class="viewer-title">{title}</h2>{/if}
|
|
138
|
+
{#if subtitle || status}
|
|
139
|
+
<p class="viewer-subtitle">
|
|
140
|
+
{#if subtitle}<span>{subtitle}</span>{/if}
|
|
141
|
+
{#if status}<span role="status">{subtitle ? ' · ' : ''}{status}</span>{/if}
|
|
142
|
+
</p>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
{@render actions?.()}
|
|
146
|
+
{#if hasDetails}
|
|
147
|
+
<button type="button" class="viewer-button" aria-label="Details" aria-expanded={detailsOpen} aria-controls={detailsId} onclick={() => detailsOpen = !detailsOpen}><IconInformation /></button>
|
|
148
|
+
{/if}
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
<div class="viewer-body">
|
|
152
|
+
<div data-gallery-stage class="viewer-stage" role="group" aria-label="Photo" ontouchstart={beginSwipe} ontouchend={endSwipe}>
|
|
153
|
+
<div class="viewer-frame">
|
|
154
|
+
{#if imageUrl}
|
|
155
|
+
{#key imageKey}<img src={imageUrl} {alt} class="viewer-image" />{/key}
|
|
156
|
+
{:else}
|
|
157
|
+
<p class="viewer-message" role="status">{message || 'Loading photo…'}</p>
|
|
158
|
+
{/if}
|
|
159
|
+
</div>
|
|
160
|
+
{#if caption}<p class="viewer-caption">{caption}</p>{/if}
|
|
161
|
+
{#if older}
|
|
162
|
+
<svelte:element this={older.href ? 'a' : 'button'} type={older.href ? undefined : 'button'} href={older.href} rel={older.href ? 'prev' : undefined} class="viewer-button viewer-step previous" aria-label="Older photo" onclick={(event: MouseEvent) => step(event, older!)} role={older.href ? undefined : 'button'} tabindex="0"><IconChevronLeft /></svelte:element>
|
|
163
|
+
{/if}
|
|
164
|
+
{#if newer}
|
|
165
|
+
<svelte:element this={newer.href ? 'a' : 'button'} type={newer.href ? undefined : 'button'} href={newer.href} rel={newer.href ? 'next' : undefined} class="viewer-button viewer-step next" aria-label="Newer photo" onclick={(event: MouseEvent) => step(event, newer!)} role={newer.href ? undefined : 'button'} tabindex="0"><IconChevronRight /></svelte:element>
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{#if hasDetails}
|
|
170
|
+
<aside id={detailsId} class="viewer-details" aria-label="Photo details" hidden={!detailsOpen}>
|
|
171
|
+
{#if details.length}
|
|
172
|
+
<dl>
|
|
173
|
+
{#each details as row (row.label)}
|
|
174
|
+
<dt>{row.label}</dt>
|
|
175
|
+
<dd>{#if row.href}<a href={row.href} target="_blank" rel="noopener noreferrer">{row.value}</a>{:else}{row.value}{/if}</dd>
|
|
176
|
+
{/each}
|
|
177
|
+
</dl>
|
|
178
|
+
{/if}
|
|
179
|
+
{@render detailsExtra?.()}
|
|
180
|
+
</aside>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{#if film.length > 1}
|
|
185
|
+
<nav bind:this={strip} class="viewer-strip" aria-label="Nearby photos">
|
|
186
|
+
{#each film as item (item.id)}
|
|
187
|
+
<svelte:element
|
|
188
|
+
this={item.href ? 'a' : 'button'}
|
|
189
|
+
type={item.href ? undefined : 'button'}
|
|
190
|
+
href={item.href}
|
|
191
|
+
class="viewer-thumb"
|
|
192
|
+
style:aspect-ratio={Math.min(1.6, Math.max(0.62, item.ratio))}
|
|
193
|
+
aria-label={`Show ${item.label}`}
|
|
194
|
+
aria-current={item.id === currentId ? 'true' : undefined}
|
|
195
|
+
role={item.href ? undefined : 'button'}
|
|
196
|
+
tabindex="0"
|
|
197
|
+
onclick={(event: MouseEvent) => { event.preventDefault(); if (item.id !== currentId) onNavigate(item.id); }}
|
|
198
|
+
>{@render thumb?.(item)}</svelte:element>
|
|
199
|
+
{/each}
|
|
200
|
+
</nav>
|
|
201
|
+
{/if}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<style>
|
|
205
|
+
/* Photos read best on a dark stage, so the viewer stays dark in either theme. */
|
|
206
|
+
.viewer {
|
|
207
|
+
--viewer-ink: #eef2f6;
|
|
208
|
+
--viewer-muted: rgb(238 242 246 / 0.62);
|
|
209
|
+
--viewer-line: rgb(255 255 255 / 0.1);
|
|
210
|
+
position: relative;
|
|
211
|
+
display: flex;
|
|
212
|
+
width: 100%;
|
|
213
|
+
height: 100%;
|
|
214
|
+
flex-direction: column;
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
background: #0a0c10;
|
|
217
|
+
color: var(--viewer-ink);
|
|
218
|
+
color-scheme: dark;
|
|
219
|
+
font-family: var(--stor-font-sans, ui-sans-serif, system-ui, sans-serif);
|
|
220
|
+
}
|
|
221
|
+
.viewer-backdrop {
|
|
222
|
+
position: absolute;
|
|
223
|
+
inset: -12%;
|
|
224
|
+
width: 124%;
|
|
225
|
+
height: 124%;
|
|
226
|
+
object-fit: cover;
|
|
227
|
+
filter: blur(64px) saturate(1.5) brightness(0.6);
|
|
228
|
+
opacity: 0.9;
|
|
229
|
+
pointer-events: none;
|
|
230
|
+
}
|
|
231
|
+
.viewer::after {
|
|
232
|
+
content: '';
|
|
233
|
+
position: absolute;
|
|
234
|
+
inset: 0;
|
|
235
|
+
pointer-events: none;
|
|
236
|
+
background:
|
|
237
|
+
radial-gradient(120% 90% at 50% 45%, transparent 40%, rgb(5 6 9 / 0.55)),
|
|
238
|
+
linear-gradient(rgb(5 6 9 / 0.45), transparent 18%, transparent 78%, rgb(5 6 9 / 0.6));
|
|
239
|
+
}
|
|
240
|
+
.viewer-bar, .viewer-strip { position: relative; z-index: 1; }
|
|
241
|
+
.viewer-bar { display: flex; min-height: 3.5rem; align-items: center; gap: 2px; padding: calc(env(safe-area-inset-top) + 6px) 8px 4px; }
|
|
242
|
+
.viewer-heading { min-width: 0; flex: 1; padding: 0 6px; }
|
|
243
|
+
.viewer-heading :global(.viewer-title), .viewer-title {
|
|
244
|
+
margin: 0;
|
|
245
|
+
overflow: hidden;
|
|
246
|
+
font-family: var(--stor-font-serif, ui-serif, Georgia, serif);
|
|
247
|
+
font-size: 16px;
|
|
248
|
+
font-weight: 600;
|
|
249
|
+
line-height: 1.2;
|
|
250
|
+
text-overflow: ellipsis;
|
|
251
|
+
white-space: nowrap;
|
|
252
|
+
}
|
|
253
|
+
.viewer-subtitle { margin: 0; overflow: hidden; color: var(--viewer-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
|
254
|
+
.viewer-bar :global(.viewer-button) {
|
|
255
|
+
display: grid;
|
|
256
|
+
width: 44px;
|
|
257
|
+
height: 44px;
|
|
258
|
+
flex: none;
|
|
259
|
+
place-items: center;
|
|
260
|
+
border: 0;
|
|
261
|
+
border-radius: 12px;
|
|
262
|
+
background: transparent;
|
|
263
|
+
color: rgb(238 242 246 / 0.86);
|
|
264
|
+
transition: background-color 160ms ease, color 160ms ease;
|
|
265
|
+
}
|
|
266
|
+
.viewer :global(.viewer-button svg) { width: 20px; height: 20px; }
|
|
267
|
+
.viewer :global(.viewer-button:hover) { background: rgb(255 255 255 / 0.1); color: #fff; }
|
|
268
|
+
.viewer :global(.viewer-button:focus-visible) { outline: 2px solid #fff; outline-offset: 1px; }
|
|
269
|
+
.viewer-body { position: relative; z-index: 1; display: flex; min-height: 0; flex: 1; }
|
|
270
|
+
.viewer-stage { position: relative; display: flex; min-width: 0; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 8px 12px; }
|
|
271
|
+
.viewer-frame { display: flex; min-height: 0; width: 100%; flex: 1; align-items: center; justify-content: center; }
|
|
272
|
+
.viewer-image { max-width: 100%; max-height: 100%; border-radius: 3px; object-fit: contain; box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.65); animation: viewer-in 260ms ease-out; }
|
|
273
|
+
@keyframes viewer-in { from { opacity: 0.4; } }
|
|
274
|
+
.viewer-message { margin: 0; color: var(--viewer-muted); font-size: 14px; }
|
|
275
|
+
.viewer-caption { max-width: 42rem; margin: 0; color: var(--viewer-ink); font-family: var(--stor-font-serif, ui-serif, Georgia, serif); font-size: 16px; font-style: italic; font-weight: 500; text-align: center; }
|
|
276
|
+
.viewer-step {
|
|
277
|
+
position: absolute;
|
|
278
|
+
top: 50%;
|
|
279
|
+
display: grid;
|
|
280
|
+
width: 44px;
|
|
281
|
+
height: 44px;
|
|
282
|
+
margin-top: -22px;
|
|
283
|
+
place-items: center;
|
|
284
|
+
border: 0;
|
|
285
|
+
border-radius: 999px;
|
|
286
|
+
background: rgb(255 255 255 / 0.08);
|
|
287
|
+
color: rgb(238 242 246 / 0.86);
|
|
288
|
+
-webkit-backdrop-filter: blur(10px);
|
|
289
|
+
backdrop-filter: blur(10px);
|
|
290
|
+
cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
.viewer-step :global(svg) { width: 24px; height: 24px; }
|
|
293
|
+
.viewer-step:hover { background: rgb(255 255 255 / 0.16); color: #fff; }
|
|
294
|
+
.viewer-step:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
|
|
295
|
+
.viewer-step.previous { left: 8px; }
|
|
296
|
+
.viewer-step.next { right: 8px; }
|
|
297
|
+
.viewer-details {
|
|
298
|
+
position: absolute;
|
|
299
|
+
inset: auto 8px 8px;
|
|
300
|
+
z-index: 3;
|
|
301
|
+
display: grid;
|
|
302
|
+
max-height: 62%;
|
|
303
|
+
gap: 14px;
|
|
304
|
+
overflow-y: auto;
|
|
305
|
+
border-radius: 22px;
|
|
306
|
+
background: rgb(20 22 28 / 0.78);
|
|
307
|
+
padding: 20px;
|
|
308
|
+
box-shadow: inset 0 0 0 1px var(--viewer-line), 0 20px 50px rgb(0 0 0 / 0.4);
|
|
309
|
+
-webkit-backdrop-filter: blur(28px) saturate(1.3);
|
|
310
|
+
backdrop-filter: blur(28px) saturate(1.3);
|
|
311
|
+
}
|
|
312
|
+
.viewer-details[hidden] { display: none; }
|
|
313
|
+
dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px 14px; margin: 0; }
|
|
314
|
+
dt { color: rgb(238 242 246 / 0.48); font-size: 10.5px; font-weight: 600; letter-spacing: 0.07em; line-height: 1.7; text-transform: uppercase; }
|
|
315
|
+
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: rgb(238 242 246 / 0.92); font-size: 13px; line-height: 1.5; }
|
|
316
|
+
dd a { color: inherit; text-decoration: underline; text-decoration-color: rgb(255 255 255 / 0.3); text-underline-offset: 3px; }
|
|
317
|
+
dd a:focus-visible, .viewer-thumb:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
|
|
318
|
+
.viewer-strip {
|
|
319
|
+
display: flex;
|
|
320
|
+
height: 72px;
|
|
321
|
+
align-items: center;
|
|
322
|
+
gap: 4px;
|
|
323
|
+
overflow-x: auto;
|
|
324
|
+
padding: 8px 50% calc(env(safe-area-inset-bottom) + 12px);
|
|
325
|
+
scrollbar-width: none;
|
|
326
|
+
-webkit-mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
|
|
327
|
+
mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
|
|
328
|
+
}
|
|
329
|
+
.viewer-strip::-webkit-scrollbar { display: none; }
|
|
330
|
+
.viewer-thumb {
|
|
331
|
+
display: block;
|
|
332
|
+
height: 40px;
|
|
333
|
+
flex: none;
|
|
334
|
+
overflow: hidden;
|
|
335
|
+
border: 0;
|
|
336
|
+
border-radius: 5px;
|
|
337
|
+
padding: 0;
|
|
338
|
+
background: rgb(255 255 255 / 0.06);
|
|
339
|
+
opacity: 0.5;
|
|
340
|
+
cursor: pointer;
|
|
341
|
+
transition: opacity 200ms ease, height 250ms ease, box-shadow 200ms ease;
|
|
342
|
+
}
|
|
343
|
+
.viewer-thumb:hover { opacity: 0.85; }
|
|
344
|
+
.viewer-thumb[aria-current='true'] { height: 50px; opacity: 1; box-shadow: 0 0 0 2px #fff; }
|
|
345
|
+
.viewer-thumb :global(img) { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
346
|
+
@media (min-width: 640px) {
|
|
347
|
+
.viewer-bar { padding: 12px 14px 6px; }
|
|
348
|
+
.viewer-stage { padding: 8px 72px; }
|
|
349
|
+
.viewer-step.previous { left: 16px; }
|
|
350
|
+
.viewer-step.next { right: 16px; }
|
|
351
|
+
.viewer-strip { height: 80px; }
|
|
352
|
+
.viewer-thumb { height: 44px; }
|
|
353
|
+
.viewer-thumb[aria-current='true'] { height: 56px; }
|
|
354
|
+
}
|
|
355
|
+
@media (min-width: 1024px) {
|
|
356
|
+
.viewer-details { position: relative; inset: auto; width: 300px; max-height: none; flex: none; align-self: flex-start; margin: 6px 16px 6px 0; }
|
|
357
|
+
}
|
|
358
|
+
@media (prefers-reduced-motion: reduce) {
|
|
359
|
+
.viewer-image { animation: none; }
|
|
360
|
+
.viewer-thumb { transition: none; }
|
|
361
|
+
}
|
|
362
|
+
</style>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery's persisted entities. Their names, tables, event prefixes, and
|
|
3
|
+
* fields are deployed data contracts: never rename them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EntityDefinition } from '@hearthlog/sdk/entities';
|
|
7
|
+
|
|
8
|
+
export const ENTITY_DEFINITIONS: EntityDefinition[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'gallery_photo',
|
|
11
|
+
feature: 'gallery',
|
|
12
|
+
displayName: 'Photo',
|
|
13
|
+
table: 'gallery_photos',
|
|
14
|
+
fileReference: { versionField: 'file_version_id' },
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'file_version_id', type: 'string', required: true },
|
|
17
|
+
{ name: 'filename', type: 'string', required: true },
|
|
18
|
+
{ name: 'content_type', type: 'string', required: true },
|
|
19
|
+
{ name: 'byte_size', type: 'number', required: true },
|
|
20
|
+
{ name: 'width', type: 'number', required: true },
|
|
21
|
+
{ name: 'height', type: 'number', required: true },
|
|
22
|
+
{ name: 'captured_at', type: 'string', default: '' },
|
|
23
|
+
{ name: 'imported_at', type: 'string', required: true },
|
|
24
|
+
{ name: 'timeline_day', type: 'string', required: true },
|
|
25
|
+
{ name: 'caption', type: 'string', default: '' },
|
|
26
|
+
{ name: 'favorite', type: 'boolean', default: 0 },
|
|
27
|
+
// Camera metadata read from the original's EXIF at import.
|
|
28
|
+
{ name: 'camera_make', type: 'string', default: '', optionalInPayload: true },
|
|
29
|
+
{ name: 'camera_model', type: 'string', default: '', optionalInPayload: true },
|
|
30
|
+
{ name: 'lens_model', type: 'string', default: '', optionalInPayload: true },
|
|
31
|
+
{ name: 'focal_length', type: 'number', default: 0, optionalInPayload: true },
|
|
32
|
+
{ name: 'f_number', type: 'number', default: 0, optionalInPayload: true },
|
|
33
|
+
{ name: 'exposure_time', type: 'number', default: 0, optionalInPayload: true },
|
|
34
|
+
{ name: 'iso', type: 'number', default: 0, optionalInPayload: true },
|
|
35
|
+
{ name: 'latitude', type: 'number', optionalInPayload: true },
|
|
36
|
+
{ name: 'longitude', type: 'number', optionalInPayload: true },
|
|
37
|
+
],
|
|
38
|
+
indexes: [
|
|
39
|
+
{ name: 'gallery_photos_timeline_idx', columns: [
|
|
40
|
+
'tenant_id', 'log_id', 'timeline_day', 'imported_at', 'id',
|
|
41
|
+
] },
|
|
42
|
+
{ name: 'gallery_photos_favorite_idx', columns: [
|
|
43
|
+
'tenant_id', 'log_id', 'favorite', 'timeline_day', 'imported_at', 'id',
|
|
44
|
+
] },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'gallery_album',
|
|
49
|
+
feature: 'gallery',
|
|
50
|
+
displayName: 'Album',
|
|
51
|
+
table: 'gallery_albums',
|
|
52
|
+
fields: [
|
|
53
|
+
{ name: 'title', type: 'string', required: true },
|
|
54
|
+
{ name: 'description', type: 'string', default: '' },
|
|
55
|
+
{ name: 'cover_photo_id', type: 'string', default: '' },
|
|
56
|
+
],
|
|
57
|
+
indexes: [
|
|
58
|
+
{ name: 'gallery_albums_title_idx', columns: ['tenant_id', 'log_id', 'title', 'id'] },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'gallery_album_photo',
|
|
63
|
+
feature: 'gallery',
|
|
64
|
+
displayName: 'Album photo',
|
|
65
|
+
table: 'gallery_album_photos',
|
|
66
|
+
fields: [
|
|
67
|
+
{ name: 'album_id', type: 'string', required: true },
|
|
68
|
+
{ name: 'photo_id', type: 'string', required: true },
|
|
69
|
+
{ name: 'added_at', type: 'string', required: true },
|
|
70
|
+
],
|
|
71
|
+
indexes: [
|
|
72
|
+
{ name: 'gallery_album_photos_album_idx', columns: ['tenant_id', 'log_id', 'album_id', 'added_at', 'id'] },
|
|
73
|
+
{ name: 'gallery_album_photos_photo_idx', columns: ['tenant_id', 'log_id', 'photo_id', 'album_id'] },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'gallery_publication',
|
|
78
|
+
feature: 'gallery',
|
|
79
|
+
displayName: 'Gallery publication',
|
|
80
|
+
table: 'gallery_publications',
|
|
81
|
+
fields: [
|
|
82
|
+
{ name: 'owner_log_id', type: 'string', required: true },
|
|
83
|
+
{ name: 'title', type: 'string', required: true },
|
|
84
|
+
{ name: 'description', type: 'string', default: '' },
|
|
85
|
+
{ name: 'revision_id', type: 'string', required: true },
|
|
86
|
+
{ name: 'requested_at', type: 'string', required: true },
|
|
87
|
+
{ name: 'last_operation', type: 'string', default: 'publish' },
|
|
88
|
+
],
|
|
89
|
+
indexes: [
|
|
90
|
+
{ name: 'gallery_publications_owner_idx', columns: ['tenant_id', 'log_id', 'owner_log_id', 'requested_at', 'id'] },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
];
|