@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,397 @@
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
3
+ * photo), who posted it and when, its event and tags - each opens the gallery narrowed to it. Its owner changes or
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
+ * - a popup closes by going back in the history, so a page opened before it is gone would be gone with it.
6
+ */
7
+ import '@material/web/button/filled-button.js'
8
+ import '@material/web/button/text-button.js'
9
+ import '@material/web/icon/icon.js'
10
+ import '@operato/popup/ox-popup-actions.js'
11
+
12
+ import { css, html, LitElement, nothing } from 'lit'
13
+ import { customElement, property, state } from 'lit/decorators.js'
14
+
15
+ import { i18next, localize } from '@operato/i18n'
16
+ import { notify } from '@operato/layout'
17
+ import { OxPrompt } from '@operato/popup/ox-prompt.js'
18
+ import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
19
+
20
+ import { galleryApi, galleryChanged, type Post } from '../utils/gallery-api.js'
21
+ import { day, days, reasonOf, t } from '../utils/gallery-words.js'
22
+
23
+ @customElement('gallery-viewer')
24
+ export class GalleryViewer extends localize(i18next)(LitElement) {
25
+ static styles = [
26
+ CommonPopupStyles,
27
+ PopupBodyStyles,
28
+ css`
29
+ :host {
30
+ height: 100%;
31
+ }
32
+ .body {
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: var(--spacing-medium, 8px);
36
+ }
37
+ .stage {
38
+ position: relative;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ min-height: 240px;
43
+ max-height: 62vh;
44
+ background: var(--md-sys-color-surface-container-highest);
45
+ border-radius: var(--border-radius, 4px);
46
+ overflow: hidden;
47
+ user-select: none;
48
+ }
49
+ .stage img {
50
+ max-width: 100%;
51
+ max-height: 62vh;
52
+ object-fit: contain;
53
+ display: block;
54
+ }
55
+ .stage .turn {
56
+ position: absolute;
57
+ top: 50%;
58
+ transform: translateY(-50%);
59
+ display: inline-flex;
60
+ padding: 4px;
61
+ border: 0;
62
+ border-radius: 50%;
63
+ background: rgb(0 0 0 / 45%);
64
+ color: #fff;
65
+ cursor: pointer;
66
+ }
67
+ .stage .previous {
68
+ inset-inline-start: var(--spacing-small, 4px);
69
+ }
70
+ .stage .next {
71
+ inset-inline-end: var(--spacing-small, 4px);
72
+ }
73
+ .stage .index {
74
+ position: absolute;
75
+ bottom: var(--spacing-small, 4px);
76
+ inset-inline-end: var(--spacing-medium, 8px);
77
+ padding: 0 6px;
78
+ border-radius: 999px;
79
+ background: rgb(0 0 0 / 45%);
80
+ color: #fff;
81
+ font-size: var(--ui-t-small, 12px);
82
+ }
83
+ .burst {
84
+ position: absolute;
85
+ --md-icon-size: 72px;
86
+ color: #fff;
87
+ opacity: 0.9;
88
+ animation: burst 600ms ease-out forwards;
89
+ pointer-events: none;
90
+ }
91
+ @keyframes burst {
92
+ from {
93
+ transform: scale(0.6);
94
+ opacity: 0.9;
95
+ }
96
+ to {
97
+ transform: scale(1.3);
98
+ opacity: 0;
99
+ }
100
+ }
101
+ .line {
102
+ display: flex;
103
+ flex-wrap: wrap;
104
+ align-items: center;
105
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
106
+ font-size: var(--ui-t-small, 12px);
107
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
108
+ }
109
+ .heart {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ gap: 2px;
113
+ padding: 0;
114
+ border: 0;
115
+ background: none;
116
+ color: var(--md-sys-color-on-surface);
117
+ font: inherit;
118
+ font-size: var(--ui-t-body, 14px);
119
+ cursor: pointer;
120
+ }
121
+ /* The app's icons are drawn filled; an empty heart says not liked yet. */
122
+ .heart md-icon {
123
+ --md-icon-size: 22px;
124
+ font-variation-settings: 'FILL' 0;
125
+ }
126
+ .heart[aria-pressed='true'] md-icon {
127
+ color: var(--md-sys-color-error);
128
+ font-variation-settings: 'FILL' 1;
129
+ }
130
+ .link {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: 2px;
134
+ padding: 0;
135
+ border: 0;
136
+ background: none;
137
+ color: var(--md-sys-color-primary);
138
+ font: inherit;
139
+ cursor: pointer;
140
+ }
141
+ .link:hover {
142
+ text-decoration: underline;
143
+ }
144
+ .link md-icon {
145
+ --md-icon-size: 14px;
146
+ }
147
+ .caption {
148
+ margin: 0;
149
+ font-size: var(--ui-t-body, 14px);
150
+ line-height: 1.6;
151
+ white-space: pre-wrap;
152
+ overflow-wrap: anywhere;
153
+ color: var(--md-sys-color-on-surface);
154
+ }
155
+ .hidden {
156
+ padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
157
+ border-radius: var(--border-radius, 4px);
158
+ background: var(--md-sys-color-error-container);
159
+ color: var(--md-sys-color-on-error-container);
160
+ font-size: var(--ui-t-small, 12px);
161
+ }
162
+ .likers {
163
+ font-size: var(--ui-t-small, 12px);
164
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
165
+ }
166
+ textarea {
167
+ box-sizing: border-box;
168
+ width: 100%;
169
+ min-height: 56px;
170
+ font: inherit;
171
+ padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
172
+ border: 1px solid var(--ui-line, var(--md-sys-color-outline-variant));
173
+ border-radius: var(--border-radius, 4px);
174
+ background: var(--md-sys-color-surface);
175
+ color: var(--md-sys-color-on-surface);
176
+ }
177
+ .spacer {
178
+ margin-inline-end: auto;
179
+ }
180
+ `
181
+ ]
182
+
183
+ @property({ attribute: false }) post!: Post
184
+
185
+ @state() private index = 0
186
+ @state() private likers?: { id: string; name?: string }[]
187
+ @state() private hiding = false
188
+ @state() private reason = ''
189
+ @state() private burst = 0
190
+ @state() private busy = false
191
+
192
+ render() {
193
+ const post = this.post
194
+ if (!post) return nothing
195
+ const photo = post.photos[this.index]
196
+ const many = post.photos.length > 1
197
+ return html`<div class="body">
198
+ ${post.hiddenAt ? html`<div class="hidden">${t('gallery.text.hidden-note')}${post.hiddenReason ? ` · ${post.hiddenReason}` : ''}</div>` : nothing}
199
+ <div class="stage" @dblclick=${() => this.like(true, true)}>
200
+ ${photo ? html`<img src=${photo.fullpath} alt=${post.caption ?? ''} />` : nothing}
201
+ ${this.burst ? html`<md-icon class="burst" style="font-variation-settings:'FILL' 1">favorite</md-icon>` : nothing}
202
+ ${
203
+ many
204
+ ? html`${this.index > 0 ? html`<button class="turn previous" aria-label=${t('gallery.button.previous')} @click=${() => (this.index -= 1)}><md-icon>chevron_left</md-icon></button>` : nothing}
205
+ ${this.index < post.photos.length - 1 ? html`<button class="turn next" aria-label=${t('gallery.button.next')} @click=${() => (this.index += 1)}><md-icon>chevron_right</md-icon></button>` : nothing}
206
+ <span class="index">${this.index + 1} / ${post.photos.length}</span>`
207
+ : nothing
208
+ }
209
+ </div>
210
+
211
+ <div class="line">
212
+ <button
213
+ class="heart"
214
+ aria-pressed=${String(post.liked)}
215
+ aria-label=${t('gallery.button.like')}
216
+ ?disabled=${this.busy}
217
+ @click=${() => this.like(!post.liked)}
218
+ >
219
+ <md-icon>favorite</md-icon>
220
+ </button>
221
+ <button class="link" ?disabled=${!post.likeCount} @click=${() => this.showLikers()}>
222
+ ${t('gallery.text.like-count', { count: post.likeCount })}
223
+ </button>
224
+ <span class="spacer"></span>
225
+ <button class="link" @click=${() => this.go(`gallery-page?author=${post.creator?.id}`)}>
226
+ ${post.creator?.name ?? ''}
227
+ </button>
228
+ <span>${day(post.takenAt)}</span>
229
+ </div>
230
+ ${this.likers ? html`<div class="likers">${this.likers.map(one => one.name).join(', ')}</div>` : nothing}
231
+ ${post.caption ? html`<p class="caption">${post.caption}</p>` : nothing}
232
+
233
+ <div class="line">
234
+ ${
235
+ post.event
236
+ ? html`<button class="link" @click=${() => this.go(`gallery-page?event=${post.event!.id}`)}>
237
+ <md-icon>event</md-icon>${post.event.name} · ${days(post.event.startAt, post.event.endAt)}
238
+ </button>`
239
+ : nothing
240
+ }
241
+ ${(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}
243
+ </div>
244
+
245
+ ${
246
+ this.hiding
247
+ ? html`<div>
248
+ <textarea
249
+ maxlength="200"
250
+ placeholder=${t('gallery.text.hide-reason')}
251
+ .value=${this.reason}
252
+ @input=${(e: any) => (this.reason = e.target.value)}
253
+ ></textarea>
254
+ </div>`
255
+ : nothing
256
+ }
257
+ </div>
258
+ <ox-popup-actions>
259
+ ${
260
+ post.editable
261
+ ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.edit()}
262
+ >${t('gallery.button.edit')}</md-text-button
263
+ >
264
+ <md-text-button ?disabled=${this.busy} @click=${() => this.deletePost()}
265
+ >${t('gallery.button.delete')}</md-text-button
266
+ >`
267
+ : nothing
268
+ }
269
+ ${
270
+ post.moderatable && !this.hiding
271
+ ? post.hiddenAt
272
+ ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.unhide()}
273
+ >${t('gallery.button.unhide')}</md-text-button
274
+ >`
275
+ : html`<md-text-button ?disabled=${this.busy} @click=${() => (this.hiding = true)}
276
+ >${t('gallery.button.hide')}</md-text-button
277
+ >`
278
+ : nothing
279
+ }
280
+ ${
281
+ this.hiding
282
+ ? html`<md-text-button slot="cancel" @click=${() => (this.hiding = false)}
283
+ >${t('gallery.button.cancel')}</md-text-button
284
+ >
285
+ <md-filled-button
286
+ slot="confirm"
287
+ ?disabled=${this.busy || !this.reason.trim()}
288
+ @click=${() => this.hide()}
289
+ >${t('gallery.button.hide')}</md-filled-button
290
+ >`
291
+ : html`<md-filled-button slot="confirm" @click=${() => this.close()}
292
+ >${t('gallery.button.close')}</md-filled-button
293
+ >`
294
+ }
295
+ </ox-popup-actions>`
296
+ }
297
+
298
+ private replace(post: Post) {
299
+ this.post = post
300
+ this.index = Math.min(this.index, Math.max(0, post.photos.length - 1))
301
+ galleryChanged()
302
+ }
303
+
304
+ /** Likes it, or takes the like back. A double press on the photo only likes - it never takes one back. */
305
+ private async like(like: boolean, fromPhoto = false) {
306
+ const post = this.post
307
+ if (fromPhoto) {
308
+ this.burst++
309
+ setTimeout(() => (this.burst = 0), 600)
310
+ if (post.liked) return
311
+ }
312
+ this.busy = true
313
+ try {
314
+ const state = await galleryApi.like(post.id, like)
315
+ this.post = { ...post, likeCount: state.likeCount, liked: state.liked }
316
+ if (this.likers) this.likers = await galleryApi.likers(post.id)
317
+ this.dispatchEvent(new CustomEvent('gallery-liked', { detail: this.post, bubbles: true, composed: true }))
318
+ } catch (error) {
319
+ notify({ level: 'error', message: reasonOf(error) })
320
+ } finally {
321
+ this.busy = false
322
+ }
323
+ }
324
+
325
+ private async showLikers() {
326
+ if (this.likers) {
327
+ this.likers = undefined
328
+ return
329
+ }
330
+ try {
331
+ this.likers = await galleryApi.likers(this.post.id)
332
+ } catch (error) {
333
+ notify({ level: 'error', message: reasonOf(error) })
334
+ }
335
+ }
336
+
337
+ private edit() {
338
+ this.close({ edit: this.post })
339
+ }
340
+
341
+ private async deletePost() {
342
+ const sure = await OxPrompt.open({
343
+ title: t('gallery.button.delete'),
344
+ text: t('gallery.text.delete-confirm'),
345
+ confirmButton: { text: t('gallery.button.delete') },
346
+ cancelButton: { text: t('gallery.button.cancel') }
347
+ })
348
+ if (!sure) return
349
+ this.busy = true
350
+ try {
351
+ await galleryApi.remove(this.post.id)
352
+ notify({ message: t('gallery.text.deleted') })
353
+ galleryChanged()
354
+ this.close()
355
+ } catch (error) {
356
+ notify({ level: 'error', message: reasonOf(error) })
357
+ } finally {
358
+ this.busy = false
359
+ }
360
+ }
361
+
362
+ private async hide() {
363
+ this.busy = true
364
+ try {
365
+ const own = this.post.editable
366
+ this.replace(await galleryApi.hide(this.post.id, this.reason.trim()))
367
+ this.hiding = false
368
+ this.reason = ''
369
+ /* Its owner is told - unless the one hiding it is its owner. */
370
+ notify({ message: own ? t('gallery.text.hidden-own') : t('gallery.text.hidden-done') })
371
+ } catch (error) {
372
+ notify({ level: 'error', message: reasonOf(error) })
373
+ } finally {
374
+ this.busy = false
375
+ }
376
+ }
377
+
378
+ private async unhide() {
379
+ this.busy = true
380
+ try {
381
+ this.replace(await galleryApi.unhide(this.post.id))
382
+ } catch (error) {
383
+ notify({ level: 'error', message: reasonOf(error) })
384
+ } finally {
385
+ this.busy = false
386
+ }
387
+ }
388
+
389
+ private go(address: string) {
390
+ this.close({ go: address })
391
+ }
392
+
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 }) {
395
+ this.dispatchEvent(new CustomEvent('done', { detail: next, bubbles: true, composed: true }))
396
+ }
397
+ }
@@ -0,0 +1 @@
1
+ export { generateGalleryBadge, refreshGalleryBadge } from './utils/gallery-badge.js'