@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.
- package/client/bootstrap.ts +16 -0
- package/client/components/gallery-forms.ts +188 -0
- package/client/components/gallery-uploader.ts +557 -0
- package/client/components/gallery-viewer.ts +397 -0
- package/client/index.ts +1 -0
- package/client/pages/gallery-page.ts +567 -0
- package/client/route.ts +8 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/gallery-api.ts +157 -0
- package/client/utils/gallery-badge.ts +44 -0
- package/client/utils/gallery-words.ts +22 -0
- package/client/utils/photo-prep.ts +93 -0
- package/dist-client/bootstrap.d.ts +1 -0
- package/dist-client/bootstrap.js +16 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/gallery-forms.d.ts +31 -0
- package/dist-client/components/gallery-forms.js +223 -0
- package/dist-client/components/gallery-forms.js.map +1 -0
- package/dist-client/components/gallery-uploader.d.ts +51 -0
- package/dist-client/components/gallery-uploader.js +588 -0
- package/dist-client/components/gallery-uploader.js.map +1 -0
- package/dist-client/components/gallery-viewer.d.ts +30 -0
- package/dist-client/components/gallery-viewer.js +416 -0
- package/dist-client/components/gallery-viewer.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/pages/gallery-page.d.ts +55 -0
- package/dist-client/pages/gallery-page.js +586 -0
- package/dist-client/pages/gallery-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +9 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/gallery-api.d.ts +95 -0
- package/dist-client/utils/gallery-api.js +46 -0
- package/dist-client/utils/gallery-api.js.map +1 -0
- package/dist-client/utils/gallery-badge.d.ts +4 -0
- package/dist-client/utils/gallery-badge.js +45 -0
- package/dist-client/utils/gallery-badge.js.map +1 -0
- package/dist-client/utils/gallery-words.d.ts +7 -0
- package/dist-client/utils/gallery-words.js +20 -0
- package/dist-client/utils/gallery-words.js.map +1 -0
- package/dist-client/utils/photo-prep.d.ts +10 -0
- package/dist-client/utils/photo-prep.js +102 -0
- package/dist-client/utils/photo-prep.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +13 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +15 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/gallery/gallery-resolver.d.ts +29 -0
- package/dist-server/service/gallery/gallery-resolver.js +602 -0
- package/dist-server/service/gallery/gallery-resolver.js.map +1 -0
- package/dist-server/service/gallery/gallery-rules.d.ts +88 -0
- package/dist-server/service/gallery/gallery-rules.js +181 -0
- package/dist-server/service/gallery/gallery-rules.js.map +1 -0
- package/dist-server/service/gallery/gallery-service.d.ts +37 -0
- package/dist-server/service/gallery/gallery-service.js +234 -0
- package/dist-server/service/gallery/gallery-service.js.map +1 -0
- package/dist-server/service/gallery/gallery-types.d.ts +62 -0
- package/dist-server/service/gallery/gallery-types.js +227 -0
- package/dist-server/service/gallery/gallery-types.js.map +1 -0
- package/dist-server/service/gallery/gallery.d.ts +83 -0
- package/dist-server/service/gallery/gallery.js +330 -0
- package/dist-server/service/gallery/gallery.js.map +1 -0
- package/dist-server/service/gallery/index.d.ts +4 -0
- package/dist-server/service/gallery/index.js +8 -0
- package/dist-server/service/gallery/index.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +23 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -4
- package/server/attachment-reader.ts +14 -0
- package/server/index.ts +9 -0
- package/server/role-tasks.ts +14 -0
- package/server/service/gallery/gallery-resolver.ts +541 -0
- package/server/service/gallery/gallery-rules.ts +187 -0
- package/server/service/gallery/gallery-service.ts +254 -0
- package/server/service/gallery/gallery-types.ts +151 -0
- package/server/service/gallery/gallery.ts +266 -0
- package/server/service/gallery/index.ts +5 -0
- package/server/service/index.ts +19 -0
- package/server/tsconfig.json +11 -0
- package/test/gallery-persistence.test.ts +372 -0
- package/test/gallery-rules.test.ts +140 -0
- package/test/open-doors.test.ts +62 -0
- package/test/photo-prep.test.ts +61 -0
- package/test/translations.test.ts +68 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +91 -0
- package/translations/es.json +91 -0
- package/translations/ja.json +91 -0
- package/translations/ko.json +91 -0
- package/translations/ms.json +91 -0
- package/translations/zh.json +91 -0
- 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
|
+
}
|