@things-factory/gallery 0.0.0-stage → 10.1.223

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 (102) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/components/gallery-forms.ts +188 -0
  3. package/client/components/gallery-uploader.ts +557 -0
  4. package/client/components/gallery-viewer.ts +397 -0
  5. package/client/index.ts +1 -0
  6. package/client/pages/gallery-page.ts +567 -0
  7. package/client/route.ts +8 -0
  8. package/client/tsconfig.json +13 -0
  9. package/client/utils/gallery-api.ts +157 -0
  10. package/client/utils/gallery-badge.ts +44 -0
  11. package/client/utils/gallery-words.ts +22 -0
  12. package/client/utils/photo-prep.ts +93 -0
  13. package/dist-client/bootstrap.d.ts +1 -0
  14. package/dist-client/bootstrap.js +16 -0
  15. package/dist-client/bootstrap.js.map +1 -0
  16. package/dist-client/components/gallery-forms.d.ts +31 -0
  17. package/dist-client/components/gallery-forms.js +223 -0
  18. package/dist-client/components/gallery-forms.js.map +1 -0
  19. package/dist-client/components/gallery-uploader.d.ts +51 -0
  20. package/dist-client/components/gallery-uploader.js +588 -0
  21. package/dist-client/components/gallery-uploader.js.map +1 -0
  22. package/dist-client/components/gallery-viewer.d.ts +30 -0
  23. package/dist-client/components/gallery-viewer.js +416 -0
  24. package/dist-client/components/gallery-viewer.js.map +1 -0
  25. package/dist-client/index.d.ts +1 -0
  26. package/dist-client/index.js +2 -0
  27. package/dist-client/index.js.map +1 -0
  28. package/dist-client/pages/gallery-page.d.ts +55 -0
  29. package/dist-client/pages/gallery-page.js +586 -0
  30. package/dist-client/pages/gallery-page.js.map +1 -0
  31. package/dist-client/route.d.ts +1 -0
  32. package/dist-client/route.js +9 -0
  33. package/dist-client/route.js.map +1 -0
  34. package/dist-client/tsconfig.tsbuildinfo +1 -0
  35. package/dist-client/utils/gallery-api.d.ts +95 -0
  36. package/dist-client/utils/gallery-api.js +46 -0
  37. package/dist-client/utils/gallery-api.js.map +1 -0
  38. package/dist-client/utils/gallery-badge.d.ts +4 -0
  39. package/dist-client/utils/gallery-badge.js +45 -0
  40. package/dist-client/utils/gallery-badge.js.map +1 -0
  41. package/dist-client/utils/gallery-words.d.ts +7 -0
  42. package/dist-client/utils/gallery-words.js +20 -0
  43. package/dist-client/utils/gallery-words.js.map +1 -0
  44. package/dist-client/utils/photo-prep.d.ts +10 -0
  45. package/dist-client/utils/photo-prep.js +102 -0
  46. package/dist-client/utils/photo-prep.js.map +1 -0
  47. package/dist-server/attachment-reader.d.ts +1 -0
  48. package/dist-server/attachment-reader.js +13 -0
  49. package/dist-server/attachment-reader.js.map +1 -0
  50. package/dist-server/index.d.ts +1 -0
  51. package/dist-server/index.js +11 -0
  52. package/dist-server/index.js.map +1 -0
  53. package/dist-server/role-tasks.d.ts +2 -0
  54. package/dist-server/role-tasks.js +15 -0
  55. package/dist-server/role-tasks.js.map +1 -0
  56. package/dist-server/service/gallery/gallery-resolver.d.ts +29 -0
  57. package/dist-server/service/gallery/gallery-resolver.js +602 -0
  58. package/dist-server/service/gallery/gallery-resolver.js.map +1 -0
  59. package/dist-server/service/gallery/gallery-rules.d.ts +88 -0
  60. package/dist-server/service/gallery/gallery-rules.js +181 -0
  61. package/dist-server/service/gallery/gallery-rules.js.map +1 -0
  62. package/dist-server/service/gallery/gallery-service.d.ts +37 -0
  63. package/dist-server/service/gallery/gallery-service.js +234 -0
  64. package/dist-server/service/gallery/gallery-service.js.map +1 -0
  65. package/dist-server/service/gallery/gallery-types.d.ts +62 -0
  66. package/dist-server/service/gallery/gallery-types.js +227 -0
  67. package/dist-server/service/gallery/gallery-types.js.map +1 -0
  68. package/dist-server/service/gallery/gallery.d.ts +83 -0
  69. package/dist-server/service/gallery/gallery.js +330 -0
  70. package/dist-server/service/gallery/gallery.js.map +1 -0
  71. package/dist-server/service/gallery/index.d.ts +4 -0
  72. package/dist-server/service/gallery/index.js +8 -0
  73. package/dist-server/service/gallery/index.js.map +1 -0
  74. package/dist-server/service/index.d.ts +7 -0
  75. package/dist-server/service/index.js +23 -0
  76. package/dist-server/service/index.js.map +1 -0
  77. package/dist-server/tsconfig.tsbuildinfo +1 -0
  78. package/package.json +48 -4
  79. package/server/attachment-reader.ts +14 -0
  80. package/server/index.ts +9 -0
  81. package/server/role-tasks.ts +14 -0
  82. package/server/service/gallery/gallery-resolver.ts +541 -0
  83. package/server/service/gallery/gallery-rules.ts +187 -0
  84. package/server/service/gallery/gallery-service.ts +254 -0
  85. package/server/service/gallery/gallery-types.ts +151 -0
  86. package/server/service/gallery/gallery.ts +266 -0
  87. package/server/service/gallery/index.ts +5 -0
  88. package/server/service/index.ts +19 -0
  89. package/server/tsconfig.json +11 -0
  90. package/test/gallery-persistence.test.ts +372 -0
  91. package/test/gallery-rules.test.ts +140 -0
  92. package/test/open-doors.test.ts +62 -0
  93. package/test/photo-prep.test.ts +61 -0
  94. package/test/translations.test.ts +68 -0
  95. package/things-factory.config.js +8 -0
  96. package/translations/en.json +91 -0
  97. package/translations/es.json +91 -0
  98. package/translations/ja.json +91 -0
  99. package/translations/ko.json +91 -0
  100. package/translations/ms.json +91 -0
  101. package/translations/zh.json +91 -0
  102. package/README.md +0 -3
@@ -0,0 +1,557 @@
1
+ /*
2
+ * Posting photos (ADR-0165 decision 3) - and changing one's own post. Photos are all it needs; the caption, the tags
3
+ * and the event are there to add. When the photos were taken is read from the first one, and the events near that day
4
+ * are offered first - one press puts the post on one.
5
+ */
6
+ import '@material/web/button/filled-button.js'
7
+ import '@material/web/button/text-button.js'
8
+ import '@material/web/icon/icon.js'
9
+ import '@operato/input/ox-input-file.js'
10
+ import '@operato/popup/ox-popup-actions.js'
11
+
12
+ import { css, html, LitElement, nothing } from 'lit'
13
+ import { customElement, property, query, state } from 'lit/decorators.js'
14
+
15
+ import { i18next, localize } from '@operato/i18n'
16
+ import { notify } from '@operato/layout'
17
+ import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
18
+ import { isMobileDevice } from '@operato/utils'
19
+
20
+ import {
21
+ galleryApi,
22
+ galleryChanged,
23
+ type EventChoice,
24
+ type Photo,
25
+ type Post,
26
+ type Space
27
+ } from '../utils/gallery-api.js'
28
+ import { day, reasonOf, t } from '../utils/gallery-words.js'
29
+ import { shrunk, takenAtOf } from '../utils/photo-prep.js'
30
+
31
+ const MAX_PHOTOS = 20
32
+ const MAX_CAPTION = 500
33
+ const MAX_TAGS = 10
34
+ /* The events shown before 「more」 - the near ones are among them. */
35
+ const FIRST_CHOICES = 4
36
+
37
+ type Added = { file: File; url: string }
38
+
39
+ /** yyyy-mm-dd of a date in the reader's own time. */
40
+ const dateText = (value: Date) =>
41
+ `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
42
+
43
+ @customElement('gallery-uploader')
44
+ export class GalleryUploader extends localize(i18next)(LitElement) {
45
+ static styles = [
46
+ CommonPopupStyles,
47
+ PopupBodyStyles,
48
+ css`
49
+ :host {
50
+ height: 100%;
51
+ }
52
+ .body {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: var(--spacing-large, 12px);
56
+ }
57
+ h4 {
58
+ margin: 0 0 var(--spacing-small, 4px);
59
+ font-size: var(--ui-t-small, 12px);
60
+ font-weight: 600;
61
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
62
+ }
63
+ .photos {
64
+ display: grid;
65
+ grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
66
+ gap: var(--spacing-small, 4px);
67
+ }
68
+ .photo {
69
+ position: relative;
70
+ aspect-ratio: 1;
71
+ border-radius: var(--border-radius, 4px);
72
+ overflow: hidden;
73
+ background: var(--md-sys-color-surface-container-high);
74
+ }
75
+ .photo img {
76
+ width: 100%;
77
+ height: 100%;
78
+ object-fit: cover;
79
+ display: block;
80
+ }
81
+ .photo button {
82
+ position: absolute;
83
+ top: 2px;
84
+ inset-inline-end: 2px;
85
+ display: inline-flex;
86
+ padding: 2px;
87
+ border: 0;
88
+ border-radius: 50%;
89
+ background: rgb(0 0 0 / 55%);
90
+ color: #fff;
91
+ cursor: pointer;
92
+ }
93
+ .photo button md-icon {
94
+ --md-icon-size: 16px;
95
+ }
96
+ .add {
97
+ display: flex;
98
+ flex-direction: column;
99
+ align-items: center;
100
+ justify-content: center;
101
+ gap: 2px;
102
+ aspect-ratio: 1;
103
+ border: 1px dashed var(--ui-line, var(--md-sys-color-outline));
104
+ border-radius: var(--border-radius, 4px);
105
+ background: none;
106
+ color: var(--md-sys-color-primary);
107
+ font: inherit;
108
+ font-size: var(--ui-t-small, 12px);
109
+ cursor: pointer;
110
+ }
111
+ .add[disabled] {
112
+ opacity: 0.4;
113
+ cursor: default;
114
+ }
115
+ ox-input-file {
116
+ display: contents;
117
+ }
118
+ textarea,
119
+ input {
120
+ box-sizing: border-box;
121
+ width: 100%;
122
+ font: inherit;
123
+ font-size: var(--ui-t-body, 14px);
124
+ padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
125
+ border: 1px solid var(--ui-line, var(--md-sys-color-outline-variant));
126
+ border-radius: var(--border-radius, 4px);
127
+ background: var(--md-sys-color-surface);
128
+ color: var(--md-sys-color-on-surface);
129
+ }
130
+ textarea {
131
+ min-height: 64px;
132
+ resize: vertical;
133
+ }
134
+ input[type='date'] {
135
+ width: auto;
136
+ }
137
+ .count {
138
+ text-align: end;
139
+ font-size: var(--ui-t-small, 12px);
140
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
141
+ }
142
+ .chips {
143
+ display: flex;
144
+ flex-wrap: wrap;
145
+ gap: var(--spacing-small, 4px);
146
+ align-items: center;
147
+ }
148
+ .chip {
149
+ display: inline-flex;
150
+ align-items: center;
151
+ gap: 2px;
152
+ padding: 2px var(--spacing-medium, 8px);
153
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
154
+ border-radius: 999px;
155
+ background: none;
156
+ color: var(--md-sys-color-on-surface);
157
+ font: inherit;
158
+ font-size: var(--ui-t-small, 12px);
159
+ line-height: 1.6;
160
+ cursor: pointer;
161
+ }
162
+ .chip[aria-pressed='true'] {
163
+ border-color: var(--md-sys-color-primary);
164
+ background: var(--md-sys-color-primary-container);
165
+ color: var(--md-sys-color-on-primary-container);
166
+ }
167
+ .chip md-icon {
168
+ --md-icon-size: 14px;
169
+ }
170
+ .chip .near {
171
+ color: var(--md-sys-color-primary);
172
+ }
173
+ .tag-input {
174
+ flex: 1;
175
+ min-width: 120px;
176
+ width: auto;
177
+ border: 0;
178
+ padding: 2px 0;
179
+ background: none;
180
+ }
181
+ .tag-input:focus {
182
+ outline: none;
183
+ }
184
+ .tags {
185
+ padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
186
+ border: 1px solid var(--ui-line, var(--md-sys-color-outline-variant));
187
+ border-radius: var(--border-radius, 4px);
188
+ }
189
+ .suggest {
190
+ margin-top: var(--spacing-small, 4px);
191
+ }
192
+ .suggest .chip {
193
+ border-style: dashed;
194
+ }
195
+ .muted {
196
+ font-size: var(--ui-t-small, 12px);
197
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
198
+ }
199
+ .row {
200
+ display: flex;
201
+ flex-wrap: wrap;
202
+ gap: var(--spacing-large, 12px);
203
+ align-items: flex-end;
204
+ }
205
+ `
206
+ ]
207
+
208
+ /** The post being changed - nothing when posting a new one. */
209
+ @property({ attribute: false }) post?: Post
210
+ /** The photos the page picked before the window opened. */
211
+ @property({ attribute: false }) files: File[] = []
212
+ /** Where a new post goes - the screen it was opened from. */
213
+ @property() space: Space = 'COMPANY'
214
+ @property() eventId?: string | null
215
+
216
+ @state() private added: Added[] = []
217
+ @state() private kept: Photo[] = []
218
+ @state() private caption = ''
219
+ @state() private tags: string[] = []
220
+ @state() private tagText = ''
221
+ @state() private suggestions: { tag: string; count: number }[] = []
222
+ @state() private chosenSpace: Space = 'COMPANY'
223
+ @state() private chosenEvent: string | null = null
224
+ @state() private takenOn = dateText(new Date())
225
+ @state() private choices: EventChoice[] = []
226
+ @state() private allChoices = false
227
+ @state() private progress?: number
228
+ @state() private busy = false
229
+
230
+ @query('#pick') private picker!: any
231
+ @query('#shoot') private camera!: any
232
+
233
+ private suggestTimer?: ReturnType<typeof setTimeout>
234
+
235
+ render() {
236
+ const total = this.kept.length + this.added.length
237
+ const full = total >= MAX_PHOTOS
238
+ const company = this.chosenSpace === 'COMPANY'
239
+ const shown = this.allChoices ? this.choices : this.choices.slice(0, FIRST_CHOICES)
240
+ const chosen = this.choices.find(one => one.id === this.chosenEvent)
241
+ return html`<div class="body">
242
+ <section>
243
+ <div class="photos">
244
+ ${this.kept.map(
245
+ one =>
246
+ html`<div class="photo">
247
+ <img src=${one.thumbnail || one.fullpath} alt="" />
248
+ <button
249
+ aria-label=${t('gallery.button.remove-photo')}
250
+ @click=${() => (this.kept = this.kept.filter(other => other !== one))}
251
+ >
252
+ <md-icon>close</md-icon>
253
+ </button>
254
+ </div>`
255
+ )}
256
+ ${this.added.map(
257
+ one =>
258
+ html`<div class="photo">
259
+ <img src=${one.url} alt="" />
260
+ <button aria-label=${t('gallery.button.remove-photo')} @click=${() => this.drop(one)}>
261
+ <md-icon>close</md-icon>
262
+ </button>
263
+ </div>`
264
+ )}
265
+ <button class="add" ?disabled=${full || this.busy} @click=${() => this.picker?.pick()}>
266
+ <md-icon>add_photo_alternate</md-icon>${t('gallery.button.add-photos')}
267
+ </button>
268
+ ${
269
+ isMobileDevice()
270
+ ? html`<button class="add" ?disabled=${full || this.busy} @click=${() => this.camera?.pick()}>
271
+ <md-icon>photo_camera</md-icon>${t('gallery.button.shoot-more')}
272
+ </button>`
273
+ : nothing
274
+ }
275
+ </div>
276
+ <ox-input-file
277
+ id="pick"
278
+ quiet
279
+ outside-trigger
280
+ hide-filelist
281
+ multiple
282
+ accept="image/*"
283
+ .progress=${this.progress}
284
+ @change=${(e: CustomEvent) => this.take(e)}
285
+ ></ox-input-file>
286
+ <ox-input-file
287
+ id="shoot"
288
+ quiet
289
+ outside-trigger
290
+ hide-filelist
291
+ accept="image/*"
292
+ capture="environment"
293
+ @change=${(e: CustomEvent) => this.take(e)}
294
+ ></ox-input-file>
295
+ ${full ? html`<div class="muted">${t('gallery.text.photos-full', { count: MAX_PHOTOS })}</div>` : nothing}
296
+ </section>
297
+
298
+ <section>
299
+ <textarea
300
+ maxlength=${MAX_CAPTION}
301
+ placeholder=${t('gallery.text.caption-placeholder')}
302
+ .value=${this.caption}
303
+ @input=${(e: any) => (this.caption = e.target.value)}
304
+ ></textarea>
305
+ ${this.caption.length > MAX_CAPTION * 0.8 ? html`<div class="count">${this.caption.length} / ${MAX_CAPTION}</div>` : nothing}
306
+ </section>
307
+
308
+ <section>
309
+ <h4>${t('gallery.field.tags')}</h4>
310
+ <div class="chips tags">
311
+ ${this.tags.map(tag => html`<span class="chip">#${tag}<md-icon role="button" aria-label=${t('gallery.button.remove-tag')} @click=${() => (this.tags = this.tags.filter(one => one !== tag))}>close</md-icon></span>`)}
312
+ ${
313
+ this.tags.length < MAX_TAGS
314
+ ? html`<input
315
+ class="tag-input"
316
+ .value=${this.tagText}
317
+ placeholder=${this.tags.length ? '' : t('gallery.text.tag-placeholder')}
318
+ @input=${(e: any) => this.typeTag(e.target.value)}
319
+ @keydown=${(e: KeyboardEvent) => this.tagKey(e)}
320
+ @blur=${() => this.addTag(this.tagText)}
321
+ />`
322
+ : nothing
323
+ }
324
+ </div>
325
+ ${
326
+ this.suggestions.length
327
+ ? html`<div class="chips suggest">
328
+ ${this.suggestions.filter(one => !this.tags.some(tag => tag.toLowerCase() === one.tag.toLowerCase())).map(one => html`<button class="chip" @mousedown=${(e: Event) => e.preventDefault()} @click=${() => this.addTag(one.tag)}>#${one.tag}</button>`)}
329
+ </div>`
330
+ : nothing
331
+ }
332
+ </section>
333
+
334
+ <section class="row">
335
+ <div>
336
+ <h4>${t('gallery.field.space')}</h4>
337
+ <div class="chips">
338
+ ${(['COMPANY', 'PERSONAL'] as Space[]).map(
339
+ space =>
340
+ html`<button
341
+ class="chip"
342
+ aria-pressed=${String(this.chosenSpace === space)}
343
+ @click=${() => this.chooseSpace(space)}
344
+ >
345
+ ${t(`gallery.space.${space}`)}
346
+ </button>`
347
+ )}
348
+ </div>
349
+ </div>
350
+ <div>
351
+ <h4>${t('gallery.field.taken-on')}</h4>
352
+ <input type="date" .value=${this.takenOn} @change=${(e: any) => this.changeDay(e.target.value)} />
353
+ </div>
354
+ </section>
355
+
356
+ ${
357
+ company
358
+ ? html`<section>
359
+ <h4>${t('gallery.field.event')}</h4>
360
+ <div class="chips">
361
+ <button
362
+ class="chip"
363
+ aria-pressed=${String(!this.chosenEvent)}
364
+ @click=${() => (this.chosenEvent = null)}
365
+ >
366
+ ${t('gallery.text.no-event')}
367
+ </button>
368
+ ${chosen && !shown.includes(chosen) ? this.choiceChip(chosen) : nothing}
369
+ ${shown.map(one => this.choiceChip(one))}
370
+ ${
371
+ this.choices.length > FIRST_CHOICES && !this.allChoices
372
+ ? html`<button class="chip" @click=${() => (this.allChoices = true)}>
373
+ ${t('gallery.button.more-events', { count: this.choices.length - FIRST_CHOICES })}
374
+ </button>`
375
+ : nothing
376
+ }
377
+ </div>
378
+ </section>`
379
+ : html`<div class="muted">${t('gallery.text.personal-note')}</div>`
380
+ }
381
+ </div>
382
+ <ox-popup-actions>
383
+ <md-text-button slot="cancel" ?disabled=${this.busy} @click=${() => this.close()}
384
+ >${t('gallery.button.cancel')}</md-text-button
385
+ >
386
+ <md-filled-button slot="confirm" ?disabled=${this.busy || total < 1} @click=${() => this.save()}
387
+ >${this.post ? t('gallery.button.save') : t('gallery.button.post-count', { count: total })}</md-filled-button
388
+ >
389
+ </ox-popup-actions>`
390
+ }
391
+
392
+ private choiceChip(one: EventChoice) {
393
+ return html`<button
394
+ class="chip"
395
+ aria-pressed=${String(this.chosenEvent === one.id)}
396
+ @click=${() => (this.chosenEvent = one.id)}
397
+ >
398
+ ${one.near ? html`<md-icon class="near">event_available</md-icon>` : nothing}${one.name}<span class="muted"
399
+ >· ${day(one.startAt)}</span
400
+ >
401
+ </button>`
402
+ }
403
+
404
+ async firstUpdated() {
405
+ const post = this.post
406
+ if (post) {
407
+ this.kept = [...post.photos]
408
+ this.caption = post.caption ?? ''
409
+ this.tags = [...(post.tags ?? [])]
410
+ this.chosenSpace = post.space
411
+ this.chosenEvent = post.event?.id ?? null
412
+ this.takenOn = dateText(new Date(post.takenAt))
413
+ } else {
414
+ this.chosenSpace = this.eventId ? 'COMPANY' : this.space
415
+ this.chosenEvent = this.eventId ?? null
416
+ }
417
+ await this.add(this.files ?? [])
418
+ await this.loadChoices()
419
+ }
420
+
421
+ disconnectedCallback() {
422
+ for (const one of this.added) URL.revokeObjectURL(one.url)
423
+ clearTimeout(this.suggestTimer)
424
+ super.disconnectedCallback()
425
+ }
426
+
427
+ private async take(e: CustomEvent) {
428
+ const picked = [...((e.detail as File[]) ?? [])]
429
+ /* The picker's own reset says `change` again, with nothing - that one is let go. */
430
+ if (!picked.length) return
431
+ ;(e.target as any).reset?.()
432
+ const before = this.kept.length + this.added.length
433
+ await this.add(picked)
434
+ /* The first photo says the day - the events near it come first. */
435
+ if (!before && !this.post) await this.loadChoices()
436
+ }
437
+
438
+ private async add(files: File[]) {
439
+ const room = MAX_PHOTOS - this.kept.length - this.added.length
440
+ const photos = files
441
+ .filter(file => /^image\//i.test(file.type) || /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(file.name))
442
+ .slice(0, Math.max(0, room))
443
+ if (photos.length < files.length)
444
+ notify({ level: 'warn', message: t('gallery.text.photos-skipped', { count: files.length - photos.length }) })
445
+ if (!photos.length) return
446
+ const first = !this.kept.length && !this.added.length && !this.post
447
+ this.added = [...this.added, ...photos.map(file => ({ file, url: URL.createObjectURL(file) }))]
448
+ if (first) {
449
+ const takenAt = await takenAtOf(photos[0])
450
+ if (takenAt) this.takenOn = dateText(takenAt)
451
+ }
452
+ }
453
+
454
+ private drop(one: Added) {
455
+ URL.revokeObjectURL(one.url)
456
+ this.added = this.added.filter(other => other !== one)
457
+ }
458
+
459
+ private takenAt(): string {
460
+ const [y, m, d] = this.takenOn.split('-').map(Number)
461
+ const now = new Date()
462
+ /* A day chosen without its hour keeps the hour it was posted - today's photo is not dated midnight. */
463
+ const at = y ? new Date(y, m - 1, d, now.getHours(), now.getMinutes()) : now
464
+ return at.toISOString()
465
+ }
466
+
467
+ private async loadChoices() {
468
+ try {
469
+ /* Offered, not chosen: the events near that day come first, and one press puts the post on one. */
470
+ this.choices = await galleryApi.eventChoices(this.takenAt())
471
+ } catch {
472
+ this.choices = []
473
+ }
474
+ }
475
+
476
+ private async changeDay(value: string) {
477
+ if (!value) return
478
+ this.takenOn = value
479
+ await this.loadChoices()
480
+ }
481
+
482
+ private chooseSpace(space: Space) {
483
+ this.chosenSpace = space
484
+ if (space === 'PERSONAL') this.chosenEvent = null
485
+ }
486
+
487
+ private typeTag(value: string) {
488
+ if (/[,\s]$/.test(value) && value.trim()) {
489
+ this.addTag(value)
490
+ return
491
+ }
492
+ this.tagText = value
493
+ clearTimeout(this.suggestTimer)
494
+ this.suggestTimer = setTimeout(async () => {
495
+ try {
496
+ this.suggestions = await galleryApi.tags(this.tagText)
497
+ } catch {
498
+ this.suggestions = []
499
+ }
500
+ }, 200)
501
+ }
502
+
503
+ private tagKey(e: KeyboardEvent) {
504
+ if (e.isComposing) return
505
+ if (e.key === 'Enter') {
506
+ e.preventDefault()
507
+ this.addTag(this.tagText)
508
+ } else if (e.key === 'Backspace' && !this.tagText && this.tags.length) {
509
+ this.tags = this.tags.slice(0, -1)
510
+ }
511
+ }
512
+
513
+ private addTag(raw: string) {
514
+ const tag = raw.replace(/[,]/g, ' ').trim().replace(/^#+/, '').replace(/\s+/g, ' ').trim()
515
+ 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)]
518
+ }
519
+
520
+ private async save() {
521
+ this.addTag(this.tagText)
522
+ this.busy = true
523
+ try {
524
+ const files = await Promise.all(this.added.map(one => shrunk(one.file)))
525
+ const input = {
526
+ space: this.chosenSpace,
527
+ caption: this.caption.trim(),
528
+ takenAt: this.takenAt(),
529
+ tags: this.tags,
530
+ eventId: this.chosenSpace === 'COMPANY' ? this.chosenEvent : null
531
+ }
532
+ const uploading = (fraction: number) => (this.progress = fraction)
533
+ const post = this.post
534
+ ? await galleryApi.update(
535
+ this.post.id,
536
+ input,
537
+ files,
538
+ this.post.photos.filter(one => !this.kept.includes(one)).map(one => one.id),
539
+ uploading
540
+ )
541
+ : await galleryApi.create(input, files, uploading)
542
+ notify({ message: this.post ? t('gallery.text.saved') : t('gallery.text.posted') })
543
+ galleryChanged()
544
+ this.dispatchEvent(new CustomEvent('gallery-posted', { detail: post, bubbles: true, composed: true }))
545
+ this.close()
546
+ } catch (error) {
547
+ notify({ level: 'error', message: reasonOf(error) })
548
+ } finally {
549
+ this.progress = undefined
550
+ this.busy = false
551
+ }
552
+ }
553
+
554
+ private close() {
555
+ this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
556
+ }
557
+ }