@things-factory/gallery 10.1.234 → 10.1.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/client/bootstrap.ts +12 -0
  2. package/client/components/gallery-saver-setting-let.ts +78 -0
  3. package/client/components/gallery-show.ts +312 -0
  4. package/client/components/gallery-uploader.ts +16 -1
  5. package/client/components/gallery-viewer.ts +17 -4
  6. package/client/pages/gallery-page.ts +18 -16
  7. package/client/utils/gallery-api.ts +4 -2
  8. package/client/utils/gallery-saver.ts +72 -0
  9. package/dist-client/bootstrap.d.ts +1 -0
  10. package/dist-client/bootstrap.js +10 -0
  11. package/dist-client/bootstrap.js.map +1 -1
  12. package/dist-client/components/gallery-saver-setting-let.d.ts +11 -0
  13. package/dist-client/components/gallery-saver-setting-let.js +81 -0
  14. package/dist-client/components/gallery-saver-setting-let.js.map +1 -0
  15. package/dist-client/components/gallery-show.d.ts +46 -0
  16. package/dist-client/components/gallery-show.js +316 -0
  17. package/dist-client/components/gallery-show.js.map +1 -0
  18. package/dist-client/components/gallery-uploader.d.ts +2 -0
  19. package/dist-client/components/gallery-uploader.js +20 -1
  20. package/dist-client/components/gallery-uploader.js.map +1 -1
  21. package/dist-client/components/gallery-viewer.d.ts +2 -2
  22. package/dist-client/components/gallery-viewer.js +16 -3
  23. package/dist-client/components/gallery-viewer.js.map +1 -1
  24. package/dist-client/pages/gallery-page.d.ts +0 -1
  25. package/dist-client/pages/gallery-page.js +20 -16
  26. package/dist-client/pages/gallery-page.js.map +1 -1
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/dist-client/utils/gallery-api.d.ts +4 -0
  29. package/dist-client/utils/gallery-api.js +1 -1
  30. package/dist-client/utils/gallery-api.js.map +1 -1
  31. package/dist-client/utils/gallery-saver.d.ts +6 -0
  32. package/dist-client/utils/gallery-saver.js +69 -0
  33. package/dist-client/utils/gallery-saver.js.map +1 -0
  34. package/dist-server/service/gallery/gallery-resolver.js +11 -2
  35. package/dist-server/service/gallery/gallery-resolver.js.map +1 -1
  36. package/dist-server/service/gallery/gallery-rules.d.ts +4 -2
  37. package/dist-server/service/gallery/gallery-rules.js +4 -3
  38. package/dist-server/service/gallery/gallery-rules.js.map +1 -1
  39. package/dist-server/service/gallery/gallery-types.d.ts +3 -0
  40. package/dist-server/service/gallery/gallery-types.js +12 -0
  41. package/dist-server/service/gallery/gallery-types.js.map +1 -1
  42. package/dist-server/service/gallery/gallery.d.ts +2 -1
  43. package/dist-server/service/gallery/gallery.js +7 -2
  44. package/dist-server/service/gallery/gallery.js.map +1 -1
  45. package/dist-server/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +8 -7
  47. package/server/service/gallery/gallery-resolver.ts +9 -2
  48. package/server/service/gallery/gallery-rules.ts +5 -4
  49. package/server/service/gallery/gallery-types.ts +9 -0
  50. package/server/service/gallery/gallery.ts +7 -2
  51. package/test/gallery-persistence.test.ts +24 -3
  52. package/test/gallery-rules.test.ts +10 -4
  53. package/translations/en.json +15 -2
  54. package/translations/es.json +15 -2
  55. package/translations/ja.json +15 -2
  56. package/translations/ko.json +15 -2
  57. package/translations/ms.json +15 -2
  58. package/translations/zh.json +15 -2
@@ -1,5 +1,11 @@
1
+ import { html } from 'lit'
2
+
1
3
  import { navigate, store } from '@operato/shell'
2
4
  import { ADD_USER_MENU } from '@things-factory/apptool-base/client'
5
+ import { ADD_SETTING } from '@things-factory/setting-base/dist-client'
6
+
7
+ import './components/gallery-saver-setting-let.js'
8
+ import { watchForSaver } from './utils/gallery-saver.js'
3
9
 
4
10
  /* One's own gallery is in the menu the header's avatar opens, right after the profile (ADR-0165 decision 6). */
5
11
  export default function bootstrap() {
@@ -13,4 +19,10 @@ export default function bootstrap() {
13
19
  action: () => navigate('gallery-page?tab=mine')
14
20
  }
15
21
  })
22
+ /* The screen saver is the device's, so it is set among one's own settings (ADR-0165 decision 9). */
23
+ store.dispatch({
24
+ type: ADD_SETTING,
25
+ setting: { seq: 30, template: html`<gallery-saver-setting-let></gallery-saver-setting-let>` }
26
+ })
27
+ watchForSaver()
16
28
  }
@@ -0,0 +1,78 @@
1
+ /*
2
+ * Settings › the gallery as the screen saver on this device (ADR-0165 decision 9): off, or after a few quiet minutes.
3
+ */
4
+ import '@material/web/radio/radio.js'
5
+
6
+ import { css, html, LitElement } from 'lit'
7
+ import { customElement, state } from 'lit/decorators.js'
8
+
9
+ import { i18next, localize } from '@operato/i18n'
10
+ import { notify } from '@operato/layout'
11
+
12
+ import { SAVER_MINUTES, saverMinutes, setSaverMinutes } from '../utils/gallery-saver.js'
13
+ import { reasonOf, t } from '../utils/gallery-words.js'
14
+
15
+ @customElement('gallery-saver-setting-let')
16
+ export class GallerySaverSettingLet extends localize(i18next)(LitElement) {
17
+ static styles = css`
18
+ div[slot='content'] {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--spacing-medium, 8px);
22
+ }
23
+ .choices {
24
+ display: flex;
25
+ flex-wrap: wrap;
26
+ gap: var(--spacing-small, 4px) var(--spacing-large, 12px);
27
+ }
28
+ label {
29
+ display: flex;
30
+ gap: 6px;
31
+ align-items: center;
32
+ font: var(--label-font);
33
+ color: var(--md-sys-color-on-surface);
34
+ }
35
+ .note {
36
+ margin: 0;
37
+ font-size: var(--ui-t-small, 12px);
38
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
39
+ }
40
+ `
41
+
42
+ @state() private minutes = 0
43
+
44
+ render() {
45
+ return html`<setting-let>
46
+ <span slot="title">${t('gallery.title.saver')}</span>
47
+ <div slot="content">
48
+ <div class="choices" role="radiogroup">
49
+ ${SAVER_MINUTES.map(
50
+ minutes => html`<label>
51
+ <md-radio
52
+ name="gallery-saver"
53
+ value=${String(minutes)}
54
+ ?checked=${this.minutes === minutes}
55
+ @change=${() => this.choose(minutes)}
56
+ ></md-radio>
57
+ ${minutes ? t('gallery.text.saver-after', { minutes }) : t('gallery.text.saver-off')}
58
+ </label>`
59
+ )}
60
+ </div>
61
+ <p class="note">${t('gallery.text.saver-note')}</p>
62
+ </div>
63
+ </setting-let>`
64
+ }
65
+
66
+ async firstUpdated() {
67
+ this.minutes = await saverMinutes()
68
+ }
69
+
70
+ private async choose(minutes: number) {
71
+ try {
72
+ await setSaverMinutes(minutes)
73
+ this.minutes = minutes
74
+ } catch (error) {
75
+ notify({ level: 'error', message: reasonOf(error) })
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,312 @@
1
+ /*
2
+ * The gallery's photos across the whole window, one at a time (ADR-0165 decision 9): seen large from a post, played as
3
+ * a slideshow from the gallery, and the screen saver after the person has been away. One show for the three - what
4
+ * differs is where it starts, whether it plays, and whether anything the person does closes it (the saver).
5
+ */
6
+ import '@material/web/icon/icon.js'
7
+
8
+ import { css, html, LitElement, nothing } from 'lit'
9
+ import { customElement, property, state } from 'lit/decorators.js'
10
+
11
+ import { i18next, localize } from '@operato/i18n'
12
+
13
+ import { galleryApi, type Filter, type Photo, type Post } from '../utils/gallery-api.js'
14
+ import { day, t } from '../utils/gallery-words.js'
15
+
16
+ /** How long one photo stays while it plays. */
17
+ const DWELL = 7000
18
+ /** The most photos one show walks through - pages of the gallery's own size are asked until this. */
19
+ const MOST = 300
20
+ const PAGE = 60
21
+
22
+ type Frame = { post: Post; photo: Photo }
23
+
24
+ export type ShowOptions = {
25
+ /** The posts the show walks through - the gallery's narrowing where it was opened. */
26
+ filter: Filter
27
+ /** Where it starts: a post and the photo in it. Without it, the first photo. */
28
+ start?: { postId: string; index: number }
29
+ /** Plays at once. */
30
+ playing?: boolean
31
+ /** The screen saver: plays in a random order, and closes at anything the person does. */
32
+ saver?: boolean
33
+ }
34
+
35
+ @customElement('gallery-show')
36
+ export class GalleryShow extends localize(i18next)(LitElement) {
37
+ static styles = css`
38
+ :host {
39
+ position: fixed;
40
+ inset: 0;
41
+ z-index: 10000;
42
+ display: block;
43
+ background: #000;
44
+ color: #fff;
45
+ user-select: none;
46
+ }
47
+ :host([saver]) {
48
+ cursor: none;
49
+ }
50
+ img {
51
+ position: absolute;
52
+ inset: 0;
53
+ width: 100%;
54
+ height: 100%;
55
+ object-fit: contain;
56
+ animation: appear 800ms ease-out;
57
+ }
58
+ @keyframes appear {
59
+ from {
60
+ opacity: 0;
61
+ }
62
+ to {
63
+ opacity: 1;
64
+ }
65
+ }
66
+ .about {
67
+ position: absolute;
68
+ inset-inline: 0;
69
+ bottom: 0;
70
+ padding: 48px 24px 20px;
71
+ background: linear-gradient(transparent, rgb(0 0 0 / 60%));
72
+ font-size: 14px;
73
+ line-height: 1.5;
74
+ }
75
+ .about .caption {
76
+ display: -webkit-box;
77
+ -webkit-line-clamp: 2;
78
+ -webkit-box-orient: vertical;
79
+ overflow: hidden;
80
+ white-space: pre-wrap;
81
+ font-size: 16px;
82
+ }
83
+ .about .who {
84
+ opacity: 0.75;
85
+ }
86
+ .bar {
87
+ position: absolute;
88
+ top: 12px;
89
+ inset-inline-end: 12px;
90
+ display: flex;
91
+ align-items: center;
92
+ gap: 8px;
93
+ }
94
+ .bar .count {
95
+ padding: 0 8px;
96
+ font-size: 13px;
97
+ opacity: 0.8;
98
+ }
99
+ button {
100
+ display: inline-flex;
101
+ padding: 8px;
102
+ border: 0;
103
+ border-radius: 50%;
104
+ background: rgb(0 0 0 / 45%);
105
+ color: #fff;
106
+ cursor: pointer;
107
+ }
108
+ button:hover {
109
+ background: rgb(255 255 255 / 20%);
110
+ }
111
+ .turn {
112
+ position: absolute;
113
+ top: 50%;
114
+ transform: translateY(-50%);
115
+ }
116
+ .previous {
117
+ inset-inline-start: 12px;
118
+ }
119
+ .next {
120
+ inset-inline-end: 12px;
121
+ }
122
+ .empty {
123
+ position: absolute;
124
+ inset: 0;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ opacity: 0.8;
129
+ }
130
+ `
131
+
132
+ /** Opens a show over the whole window, and the whole screen when the browser lets it. */
133
+ static open(options: ShowOptions): GalleryShow {
134
+ const show = document.createElement('gallery-show') as GalleryShow
135
+ show.options = options
136
+ document.body.appendChild(show)
137
+ return show
138
+ }
139
+
140
+ @property({ type: Boolean, reflect: true }) saver = false
141
+ options!: ShowOptions
142
+
143
+ @state() private frames: Frame[] = []
144
+ @state() private at = 0
145
+ @state() private playing = false
146
+ @state() private loaded = false
147
+
148
+ private timer?: ReturnType<typeof setTimeout>
149
+ private wholeScreen = false
150
+ private disposed = false
151
+
152
+ render() {
153
+ const frame = this.frames[this.at]
154
+ if (!frame)
155
+ return this.loaded
156
+ ? html`<div class="empty">${t('gallery.text.show-empty')}</div>${this.saver ? nothing : this.renderBar()}`
157
+ : nothing
158
+ const { post, photo } = frame
159
+ return html`
160
+ ${/* A new element for each photo, so it fades in. */ [photo.id].map(() => html`<img src=${photo.fullpath} alt=${post.caption ?? ''} />`)}
161
+ <div class="about">
162
+ ${post.caption ? html`<div class="caption">${post.caption}</div>` : nothing}
163
+ <div class="who">${post.creator?.name ?? ''} · ${day(post.takenAt)}${post.event ? ` · ${post.event.name}` : ''}</div>
164
+ </div>
165
+ ${
166
+ this.saver
167
+ ? nothing
168
+ : html`${this.renderBar()}
169
+ ${this.frames.length > 1
170
+ ? html`<button class="turn previous" aria-label=${t('gallery.button.previous')} @click=${() => this.step(-1)}>
171
+ <md-icon>chevron_left</md-icon>
172
+ </button>
173
+ <button class="turn next" aria-label=${t('gallery.button.next')} @click=${() => this.step(1)}>
174
+ <md-icon>chevron_right</md-icon>
175
+ </button>`
176
+ : nothing}`
177
+ }
178
+ `
179
+ }
180
+
181
+ private renderBar() {
182
+ return html`<div class="bar">
183
+ ${this.frames.length ? html`<span class="count">${this.at + 1} / ${this.frames.length}</span>` : nothing}
184
+ ${
185
+ this.frames.length > 1
186
+ ? html`<button
187
+ aria-label=${t(this.playing ? 'gallery.button.pause' : 'gallery.button.play')}
188
+ title=${t(this.playing ? 'gallery.button.pause' : 'gallery.button.play')}
189
+ @click=${() => this.play(!this.playing)}
190
+ >
191
+ <md-icon>${this.playing ? 'pause' : 'play_arrow'}</md-icon>
192
+ </button>`
193
+ : nothing
194
+ }
195
+ <button aria-label=${t('gallery.button.close')} title=${t('gallery.button.close')} @click=${() => this.close()}>
196
+ <md-icon>close</md-icon>
197
+ </button>
198
+ </div>`
199
+ }
200
+
201
+ connectedCallback() {
202
+ super.connectedCallback()
203
+ this.saver = !!this.options?.saver
204
+ window.addEventListener('keydown', this.onKey, true)
205
+ document.addEventListener('fullscreenchange', this.onFullscreen)
206
+ if (this.saver) for (const name of WAKE) window.addEventListener(name, this.onWake, true)
207
+ /* The whole screen needs the press that opened it - the saver has none, and covers the window only. */
208
+ if (!this.saver && this.requestFullscreen)
209
+ this.requestFullscreen()
210
+ .then(() => (this.wholeScreen = true))
211
+ .catch(() => {})
212
+ this.load()
213
+ }
214
+
215
+ disconnectedCallback() {
216
+ super.disconnectedCallback()
217
+ this.disposed = true
218
+ clearTimeout(this.timer)
219
+ window.removeEventListener('keydown', this.onKey, true)
220
+ document.removeEventListener('fullscreenchange', this.onFullscreen)
221
+ for (const name of WAKE) window.removeEventListener(name, this.onWake, true)
222
+ }
223
+
224
+ private async load() {
225
+ const { filter, start, saver } = this.options
226
+ const frames: Frame[] = []
227
+ try {
228
+ for (let skip = 0; frames.length < MOST; skip += PAGE) {
229
+ const page = await galleryApi.posts(filter, skip, PAGE)
230
+ for (const post of page.items) if (!post.hiddenAt) for (const photo of post.photos) frames.push({ post, photo })
231
+ if (this.disposed || skip + PAGE >= page.total) break
232
+ }
233
+ } catch {
234
+ /* The show has what came; a saver with nothing closes itself below. */
235
+ }
236
+ if (this.disposed) return
237
+ if (saver) shuffle(frames)
238
+ this.frames = frames.slice(0, MOST)
239
+ const found = start ? this.frames.findIndex(one => one.post.id === start.postId) : -1
240
+ this.at = found < 0 ? 0 : Math.min(found + start!.index, this.frames.length - 1)
241
+ this.loaded = true
242
+ if (saver && !this.frames.length) return this.close()
243
+ this.play(!!this.options.playing || !!saver)
244
+ }
245
+
246
+ private play(on: boolean) {
247
+ this.playing = on
248
+ this.schedule()
249
+ }
250
+
251
+ /** The next photo after the dwell, asked ahead so it is there when it turns. */
252
+ private schedule() {
253
+ clearTimeout(this.timer)
254
+ if (!this.playing || this.frames.length < 2) return
255
+ const next = this.frames[(this.at + 1) % this.frames.length]
256
+ new Image().src = next.photo.fullpath
257
+ this.timer = setTimeout(() => this.step(1), DWELL)
258
+ }
259
+
260
+ private step(by: number) {
261
+ const count = this.frames.length
262
+ if (!count) return
263
+ this.at = (this.at + by + count) % count
264
+ this.schedule()
265
+ }
266
+
267
+ private onKey = (e: KeyboardEvent) => {
268
+ if (this.saver) return
269
+ const handled: Record<string, () => void> = {
270
+ ArrowLeft: () => this.step(-1),
271
+ ArrowRight: () => this.step(1),
272
+ ' ': () => this.play(!this.playing),
273
+ Escape: () => this.close()
274
+ }
275
+ const act = handled[e.key]
276
+ if (!act) return
277
+ e.preventDefault()
278
+ e.stopPropagation()
279
+ act()
280
+ }
281
+
282
+ /** Leaving the whole screen (Esc there is the browser's) leaves the show too. */
283
+ private onFullscreen = () => {
284
+ if (this.wholeScreen && !document.fullscreenElement) this.close()
285
+ }
286
+
287
+ private onWake = (e: Event) => {
288
+ /* The pointer resting on the screen still says it moved, by a pixel or so - a real move is farther. */
289
+ if (e.type === 'pointermove' && Math.abs((e as PointerEvent).movementX) + Math.abs((e as PointerEvent).movementY) < 4)
290
+ return
291
+ e.preventDefault()
292
+ e.stopPropagation()
293
+ this.close()
294
+ }
295
+
296
+ close() {
297
+ if (this.disposed) return
298
+ if (this.wholeScreen && document.fullscreenElement) document.exitFullscreen().catch(() => {})
299
+ this.remove()
300
+ this.dispatchEvent(new CustomEvent('closed'))
301
+ }
302
+ }
303
+
304
+ /** What wakes the screen from the saver. */
305
+ const WAKE = ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']
306
+
307
+ function shuffle<T>(list: T[]) {
308
+ for (let i = list.length - 1; i > 0; i--) {
309
+ const j = Math.floor(Math.random() * (i + 1))
310
+ ;[list[i], list[j]] = [list[j], list[i]]
311
+ }
312
+ }
@@ -227,6 +227,8 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
227
227
  @state() private tagRefusal = ''
228
228
  @state() private suggestions: { tag: string; count: number }[] = []
229
229
  @state() private chosenSpace: Space = 'COMPANY'
230
+ /** A post of the own gallery is the person's alone until shared (decision 2) - off when posting. */
231
+ @state() private shared = false
230
232
  @state() private chosenEvent: string | null = null
231
233
  @state() private takenOn = dateText(new Date())
232
234
  @state() private choices: EventChoice[] = []
@@ -384,7 +386,18 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
384
386
  }
385
387
  </div>
386
388
  </section>`
387
- : html`<div class="muted">${t('gallery.text.personal-note')}</div>`
389
+ : html`<section>
390
+ <h4>${t('gallery.field.shared')}</h4>
391
+ <div class="chips">
392
+ <button class="chip" aria-pressed=${String(!this.shared)} @click=${() => (this.shared = false)}>
393
+ <md-icon>lock</md-icon>${t('gallery.text.only-me')}
394
+ </button>
395
+ <button class="chip" aria-pressed=${String(this.shared)} @click=${() => (this.shared = true)}>
396
+ <md-icon>group</md-icon>${t('gallery.text.everyone')}
397
+ </button>
398
+ </div>
399
+ <div class="muted">${t(this.shared ? 'gallery.text.shared-note' : 'gallery.text.only-me-note')}</div>
400
+ </section>`
388
401
  }
389
402
  </div>
390
403
  <ox-popup-actions>
@@ -416,6 +429,7 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
416
429
  this.caption = post.caption ?? ''
417
430
  this.tags = [...(post.tags ?? [])]
418
431
  this.chosenSpace = post.space
432
+ this.shared = post.shared
419
433
  this.chosenEvent = post.event?.id ?? null
420
434
  this.takenOn = dateText(new Date(post.takenAt))
421
435
  } else {
@@ -539,6 +553,7 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
539
553
  const files = await Promise.all(this.added.map(one => shrunk(one.file)))
540
554
  const input = {
541
555
  space: this.chosenSpace,
556
+ shared: this.chosenSpace === 'PERSONAL' && this.shared,
542
557
  caption: this.caption.trim(),
543
558
  takenAt: this.takenAt(),
544
559
  tags: this.tags,
@@ -1,5 +1,5 @@
1
1
  /*
2
- * One post seen large (ADR-0165 decisions 4-6): its photos one by one, the like (the heart, or a double press on the
2
+ * One post seen large (ADR-0165 decisions 4-6): its photos one by one - and across the whole screen (decision 9) - the like (the heart, or a double press on the
3
3
  * photo), who posted it and when, its event and tags - each opens the gallery narrowed to it. Its owner changes or
4
4
  * deletes it here; a moderator hides it here, with the reason, or shows it again. Where to go next is the popup's result
5
5
  * - a popup closes by going back in the history, so a page opened before it is gone would be gone with it.
@@ -70,6 +70,18 @@ export class GalleryViewer extends localize(i18next)(LitElement) {
70
70
  .stage .next {
71
71
  inset-inline-end: var(--spacing-small, 4px);
72
72
  }
73
+ .stage .large {
74
+ position: absolute;
75
+ top: var(--spacing-small, 4px);
76
+ inset-inline-end: var(--spacing-small, 4px);
77
+ display: inline-flex;
78
+ padding: 4px;
79
+ border: 0;
80
+ border-radius: 50%;
81
+ background: rgb(0 0 0 / 45%);
82
+ color: #fff;
83
+ cursor: pointer;
84
+ }
73
85
  .stage .index {
74
86
  position: absolute;
75
87
  bottom: var(--spacing-small, 4px);
@@ -198,6 +210,7 @@ export class GalleryViewer extends localize(i18next)(LitElement) {
198
210
  ${post.hiddenAt ? html`<div class="hidden">${t('gallery.text.hidden-note')}${post.hiddenReason ? ` · ${post.hiddenReason}` : ''}</div>` : nothing}
199
211
  <div class="stage" @dblclick=${() => this.like(true, true)}>
200
212
  ${photo ? html`<img src=${photo.fullpath} alt=${post.caption ?? ''} />` : nothing}
213
+ ${photo ? html`<button class="large" aria-label=${t('gallery.button.large')} title=${t('gallery.button.large')} @click=${() => this.close({ show: { postId: post.id, index: this.index } })}><md-icon>fullscreen</md-icon></button>` : nothing}
201
214
  ${this.burst ? html`<md-icon class="burst" style="font-variation-settings:'FILL' 1">favorite</md-icon>` : nothing}
202
215
  ${
203
216
  many
@@ -239,7 +252,7 @@ export class GalleryViewer extends localize(i18next)(LitElement) {
239
252
  : nothing
240
253
  }
241
254
  ${(post.tags ?? []).map(tag => html`<button class="link" @click=${() => this.go(`gallery-page?tag=${encodeURIComponent(tag)}`)}>#${tag}</button>`)}
242
- ${post.space === 'PERSONAL' ? html`<span>${t('gallery.space.PERSONAL')}</span>` : nothing}
255
+ ${post.space === 'PERSONAL' ? html`<span>${t('gallery.space.PERSONAL')}${post.shared ? '' : ` · ${t('gallery.text.only-me')}`}</span>` : nothing}
243
256
  </div>
244
257
 
245
258
  ${
@@ -390,8 +403,8 @@ export class GalleryViewer extends localize(i18next)(LitElement) {
390
403
  this.close({ go: address })
391
404
  }
392
405
 
393
- /** The next step - a page to go to, or the post to change - goes as the popup's result: the page takes it once the popup is gone. */
394
- private close(next?: { go?: string; edit?: Post }) {
406
+ /** The next step - a page to go to, the post to change, or the photo to see large - goes as the popup's result: the page takes it once the popup is gone. */
407
+ private close(next?: { go?: string; edit?: Post; show?: { postId: string; index: number } }) {
395
408
  this.dispatchEvent(new CustomEvent('done', { detail: next, bubbles: true, composed: true }))
396
409
  }
397
410
  }
@@ -9,11 +9,12 @@ import { customElement, query, state } from 'lit/decorators.js'
9
9
 
10
10
  import { i18next, localize } from '@operato/i18n'
11
11
  import { notify, openPopup } from '@operato/layout'
12
- import { navigate, PageView, store } from '@operato/shell'
12
+ import { navigate, PageView } from '@operato/shell'
13
13
  import { ScrollbarStyles } from '@operato/styles'
14
14
  import { isMobileDevice } from '@operato/utils'
15
15
  import { hasPrivilege } from '@things-factory/auth-base/dist-client'
16
16
 
17
+ import { GalleryShow } from '../components/gallery-show.js'
17
18
  import {
18
19
  galleryApi,
19
20
  type EventCard,
@@ -186,6 +187,10 @@ export class GalleryPage extends localize(i18next)(PageView) {
186
187
  top: 4px;
187
188
  inset-inline-end: 4px;
188
189
  }
190
+ .badge.private {
191
+ bottom: 4px;
192
+ inset-inline-end: 4px;
193
+ }
189
194
  .badge.hidden {
190
195
  top: 4px;
191
196
  inset-inline-start: 4px;
@@ -268,10 +273,6 @@ export class GalleryPage extends localize(i18next)(PageView) {
268
273
 
269
274
  private onChanged = () => this.load()
270
275
 
271
- private get me(): string | undefined {
272
- return (store.getState() as any).auth?.user?.id
273
- }
274
-
275
276
  get context() {
276
277
  const actions: any[] = []
277
278
  if (this.tag && this.moderator)
@@ -281,6 +282,8 @@ export class GalleryPage extends localize(i18next)(PageView) {
281
282
  if (this.tab !== 'events' || this.eventId) {
282
283
  if (isMobileDevice())
283
284
  actions.push({ title: t('gallery.button.shoot'), icon: 'photo_camera', action: () => this.camera?.pick() })
285
+ if (this.total)
286
+ actions.push({ title: t('gallery.button.slideshow'), icon: 'slideshow', action: () => GalleryShow.open({ filter: this.filter(), playing: true }) })
284
287
  actions.push({
285
288
  title: t('gallery.button.post'),
286
289
  icon: 'add_photo_alternate',
@@ -319,7 +322,7 @@ export class GalleryPage extends localize(i18next)(PageView) {
319
322
  }
320
323
 
321
324
  private get narrowed() {
322
- return !!(this.eventId || this.tag || (this.authorId && this.authorId !== this.me))
325
+ return !!(this.eventId || this.tag || this.authorId)
323
326
  }
324
327
 
325
328
  private renderNarrowing() {
@@ -333,7 +336,7 @@ export class GalleryPage extends localize(i18next)(PageView) {
333
336
  </button>
334
337
  </div>`
335
338
  }
336
- if (this.authorId && this.authorId !== this.me) {
339
+ if (this.authorId) {
337
340
  const name = this.posts[0]?.creator?.name ?? ''
338
341
  return html`<div class="narrow"><h2>${t('gallery.text.persons-gallery', { name })}</h2></div>`
339
342
  }
@@ -371,6 +374,7 @@ export class GalleryPage extends localize(i18next)(PageView) {
371
374
  ${post.photos[0] ? html`<img loading="lazy" src=${post.photos[0].thumbnail || post.photos[0].fullpath} alt=${post.caption ?? ''} />` : nothing}
372
375
  ${post.hiddenAt ? html`<span class="badge hidden"><md-icon>visibility_off</md-icon>${t('gallery.text.hidden')}</span>` : nothing}
373
376
  ${post.photos.length > 1 ? html`<span class="badge count"><md-icon>photo_library</md-icon>${post.photos.length}</span>` : nothing}
377
+ ${post.space === 'PERSONAL' && !post.shared ? html`<span class="badge private" title=${t('gallery.text.only-me')}><md-icon>lock</md-icon></span>` : nothing}
374
378
  ${post.likeCount || post.liked ? html`<span class="badge likes" ?liked=${post.liked}><md-icon>favorite</md-icon>${post.likeCount}</span>` : nothing}
375
379
  </div>
376
380
  <div class="under">
@@ -432,14 +436,9 @@ export class GalleryPage extends localize(i18next)(PageView) {
432
436
  const params = lifecycle?.params ?? {}
433
437
  this.eventId = params.event || undefined
434
438
  this.tag = params.tag || undefined
435
- this.authorId = params.author === 'me' ? this.me : params.author || undefined
436
- this.tab = this.eventId
437
- ? 'events'
438
- : this.authorId === this.me && this.authorId
439
- ? 'mine'
440
- : TABS.includes(params.tab)
441
- ? params.tab
442
- : 'company'
439
+ /* 「me」 is the own gallery - the server knows who asks; the client holds no user id. */
440
+ this.authorId = params.author === 'me' ? undefined : params.author || undefined
441
+ this.tab = this.eventId ? 'events' : params.author === 'me' ? 'mine' : TABS.includes(params.tab) ? params.tab : 'company'
443
442
  this.event = undefined
444
443
  this.posts = []
445
444
  this.updateContext()
@@ -457,7 +456,7 @@ export class GalleryPage extends localize(i18next)(PageView) {
457
456
  if (this.tag) filter.tag = this.tag
458
457
  if (this.authorId) filter.authorId = this.authorId
459
458
  else if (this.tab === 'company') filter.space = 'COMPANY'
460
- else if (this.tab === 'mine' && this.me) filter.authorId = this.me
459
+ else if (this.tab === 'mine') filter.mine = true
461
460
  return filter
462
461
  }
463
462
 
@@ -471,6 +470,8 @@ export class GalleryPage extends localize(i18next)(PageView) {
471
470
  const page = await galleryApi.posts(this.filter(), more ? this.posts.length : 0, PAGE)
472
471
  this.posts = more ? [...this.posts, ...page.items] : page.items
473
472
  this.total = page.total
473
+ /* The slideshow is offered once there is something to show. */
474
+ this.updateContext()
474
475
  if (this.eventId && !this.event)
475
476
  this.event =
476
477
  page.items.find(one => one.event)?.event ?? (await galleryApi.events()).find(one => one.id === this.eventId)
@@ -523,6 +524,7 @@ export class GalleryPage extends localize(i18next)(PageView) {
523
524
  const next = await popup.result
524
525
  if (next?.edit) this.upload([], next.edit)
525
526
  else if (next?.go) navigate(next.go)
527
+ else if (next?.show) GalleryShow.open({ filter: this.filter(), start: next.show })
526
528
  }
527
529
 
528
530
  private async openById(id: string) {
@@ -14,6 +14,7 @@ export type EventChoice = EventFacts & { near: boolean }
14
14
  export interface Post {
15
15
  id: string
16
16
  space: Space
17
+ shared: boolean
17
18
  caption?: string | null
18
19
  takenAt: string
19
20
  tags?: string[] | null
@@ -31,15 +32,16 @@ export interface Post {
31
32
 
32
33
  export interface PostInput {
33
34
  space?: Space
35
+ shared?: boolean
34
36
  caption?: string
35
37
  takenAt?: string
36
38
  tags?: string[]
37
39
  eventId?: string | null
38
40
  }
39
41
 
40
- export type Filter = { space?: Space; eventId?: string; tag?: string; authorId?: string }
42
+ export type Filter = { space?: Space; eventId?: string; tag?: string; authorId?: string; mine?: boolean; everyone?: boolean }
41
43
 
42
- const POST_FIELDS = `id space caption takenAt tags likeCount liked editable moderatable hiddenAt hiddenReason createdAt
44
+ const POST_FIELDS = `id space shared caption takenAt tags likeCount liked editable moderatable hiddenAt hiddenReason createdAt
43
45
  creator { id name } event { id name startAt endAt location } photos { id name thumbnail fullpath mimetype }`
44
46
  const EVENT_FIELDS = 'id name startAt endAt location'
45
47