@things-factory/gallery 10.1.233 → 10.1.236

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 (62) hide show
  1. package/client/bootstrap.ts +12 -0
  2. package/client/components/gallery-forms.ts +1 -1
  3. package/client/components/gallery-saver-setting-let.ts +78 -0
  4. package/client/components/gallery-show.ts +312 -0
  5. package/client/components/gallery-uploader.ts +35 -5
  6. package/client/components/gallery-viewer.ts +17 -4
  7. package/client/pages/gallery-page.ts +18 -16
  8. package/client/utils/gallery-api.ts +4 -2
  9. package/client/utils/gallery-saver.ts +72 -0
  10. package/dist-client/bootstrap.d.ts +1 -0
  11. package/dist-client/bootstrap.js +10 -0
  12. package/dist-client/bootstrap.js.map +1 -1
  13. package/dist-client/components/gallery-forms.js +1 -1
  14. package/dist-client/components/gallery-forms.js.map +1 -1
  15. package/dist-client/components/gallery-saver-setting-let.d.ts +11 -0
  16. package/dist-client/components/gallery-saver-setting-let.js +81 -0
  17. package/dist-client/components/gallery-saver-setting-let.js.map +1 -0
  18. package/dist-client/components/gallery-show.d.ts +46 -0
  19. package/dist-client/components/gallery-show.js +316 -0
  20. package/dist-client/components/gallery-show.js.map +1 -0
  21. package/dist-client/components/gallery-uploader.d.ts +5 -0
  22. package/dist-client/components/gallery-uploader.js +43 -4
  23. package/dist-client/components/gallery-uploader.js.map +1 -1
  24. package/dist-client/components/gallery-viewer.d.ts +2 -2
  25. package/dist-client/components/gallery-viewer.js +16 -3
  26. package/dist-client/components/gallery-viewer.js.map +1 -1
  27. package/dist-client/pages/gallery-page.d.ts +0 -1
  28. package/dist-client/pages/gallery-page.js +20 -16
  29. package/dist-client/pages/gallery-page.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-client/utils/gallery-api.d.ts +4 -0
  32. package/dist-client/utils/gallery-api.js +1 -1
  33. package/dist-client/utils/gallery-api.js.map +1 -1
  34. package/dist-client/utils/gallery-saver.d.ts +6 -0
  35. package/dist-client/utils/gallery-saver.js +69 -0
  36. package/dist-client/utils/gallery-saver.js.map +1 -0
  37. package/dist-server/service/gallery/gallery-resolver.js +13 -2
  38. package/dist-server/service/gallery/gallery-resolver.js.map +1 -1
  39. package/dist-server/service/gallery/gallery-rules.d.ts +4 -2
  40. package/dist-server/service/gallery/gallery-rules.js +4 -3
  41. package/dist-server/service/gallery/gallery-rules.js.map +1 -1
  42. package/dist-server/service/gallery/gallery-types.d.ts +3 -0
  43. package/dist-server/service/gallery/gallery-types.js +12 -0
  44. package/dist-server/service/gallery/gallery-types.js.map +1 -1
  45. package/dist-server/service/gallery/gallery.d.ts +2 -1
  46. package/dist-server/service/gallery/gallery.js +7 -2
  47. package/dist-server/service/gallery/gallery.js.map +1 -1
  48. package/dist-server/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +10 -9
  50. package/server/service/gallery/gallery-resolver.ts +13 -4
  51. package/server/service/gallery/gallery-rules.ts +5 -4
  52. package/server/service/gallery/gallery-types.ts +9 -0
  53. package/server/service/gallery/gallery.ts +7 -2
  54. package/test/gallery-persistence.test.ts +24 -3
  55. package/test/gallery-rules.test.ts +10 -4
  56. package/test/refusals-and-bounds.test.ts +28 -0
  57. package/translations/en.json +16 -2
  58. package/translations/es.json +16 -2
  59. package/translations/ja.json +16 -2
  60. package/translations/ko.json +16 -2
  61. package/translations/ms.json +16 -2
  62. package/translations/zh.json +16 -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
  }
@@ -153,7 +153,7 @@ export class GalleryTagForm extends localize(i18next)(LitElement) {
153
153
  >${t('gallery.field.new-tag')}<input
154
154
  .value=${this.to}
155
155
  placeholder=${this.tag}
156
- maxlength="31"
156
+ maxlength="30"
157
157
  @input=${(e: any) => (this.to = e.target.value)}
158
158
  /></label>
159
159
  <div class="note">${t('gallery.text.tag-form-note', { tag: this.tag })}</div>
@@ -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
+ }
@@ -31,6 +31,7 @@ import { shrunk, takenAtOf } from '../utils/photo-prep.js'
31
31
  const MAX_PHOTOS = 20
32
32
  const MAX_CAPTION = 500
33
33
  const MAX_TAGS = 10
34
+ const MAX_TAG = 30
34
35
  /* The events shown before 「more」 - the near ones are among them. */
35
36
  const FIRST_CHOICES = 4
36
37
 
@@ -139,6 +140,10 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
139
140
  font-size: var(--ui-t-small, 12px);
140
141
  color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
141
142
  }
143
+ .refusal {
144
+ font-size: var(--ui-t-small, 12px);
145
+ color: var(--md-sys-color-error);
146
+ }
142
147
  .chips {
143
148
  display: flex;
144
149
  flex-wrap: wrap;
@@ -218,8 +223,12 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
218
223
  @state() private caption = ''
219
224
  @state() private tags: string[] = []
220
225
  @state() private tagText = ''
226
+ /** Why the tag being typed was not taken - said under the tags, the text kept so it can be shortened. */
227
+ @state() private tagRefusal = ''
221
228
  @state() private suggestions: { tag: string; count: number }[] = []
222
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
223
232
  @state() private chosenEvent: string | null = null
224
233
  @state() private takenOn = dateText(new Date())
225
234
  @state() private choices: EventChoice[] = []
@@ -322,6 +331,7 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
322
331
  : nothing
323
332
  }
324
333
  </div>
334
+ ${this.tagRefusal ? html`<div class="refusal" role="alert">${this.tagRefusal}</div>` : nothing}
325
335
  ${
326
336
  this.suggestions.length
327
337
  ? html`<div class="chips suggest">
@@ -376,7 +386,18 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
376
386
  }
377
387
  </div>
378
388
  </section>`
379
- : 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>`
380
401
  }
381
402
  </div>
382
403
  <ox-popup-actions>
@@ -408,6 +429,7 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
408
429
  this.caption = post.caption ?? ''
409
430
  this.tags = [...(post.tags ?? [])]
410
431
  this.chosenSpace = post.space
432
+ this.shared = post.shared
411
433
  this.chosenEvent = post.event?.id ?? null
412
434
  this.takenOn = dateText(new Date(post.takenAt))
413
435
  } else {
@@ -510,20 +532,28 @@ export class GalleryUploader extends localize(i18next)(LitElement) {
510
532
  }
511
533
  }
512
534
 
513
- private addTag(raw: string) {
535
+ /** Takes the typed tag, or says why not and keeps it - a longer tag was cut at 30 without a word (2026-10-30). */
536
+ private addTag(raw: string): boolean {
514
537
  const tag = raw.replace(/[,]/g, ' ').trim().replace(/^#+/, '').replace(/\s+/g, ' ').trim()
538
+ if (tag.length > MAX_TAG) {
539
+ this.tagRefusal = t('gallery.text.tag-too-long-typed', { max: MAX_TAG, length: tag.length })
540
+ return false
541
+ }
542
+ this.tagRefusal = ''
515
543
  this.tagText = ''
516
- if (!tag || this.tags.length >= MAX_TAGS || this.tags.some(one => one.toLowerCase() === tag.toLowerCase())) return
517
- this.tags = [...this.tags, tag.slice(0, 30)]
544
+ if (!tag || this.tags.length >= MAX_TAGS || this.tags.some(one => one.toLowerCase() === tag.toLowerCase())) return true
545
+ this.tags = [...this.tags, tag]
546
+ return true
518
547
  }
519
548
 
520
549
  private async save() {
521
- this.addTag(this.tagText)
550
+ if (!this.addTag(this.tagText)) return
522
551
  this.busy = true
523
552
  try {
524
553
  const files = await Promise.all(this.added.map(one => shrunk(one.file)))
525
554
  const input = {
526
555
  space: this.chosenSpace,
556
+ shared: this.chosenSpace === 'PERSONAL' && this.shared,
527
557
  caption: this.caption.trim(),
528
558
  takenAt: this.takenAt(),
529
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) {