@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.
- package/client/bootstrap.ts +12 -0
- package/client/components/gallery-forms.ts +1 -1
- package/client/components/gallery-saver-setting-let.ts +78 -0
- package/client/components/gallery-show.ts +312 -0
- package/client/components/gallery-uploader.ts +35 -5
- package/client/components/gallery-viewer.ts +17 -4
- package/client/pages/gallery-page.ts +18 -16
- package/client/utils/gallery-api.ts +4 -2
- package/client/utils/gallery-saver.ts +72 -0
- package/dist-client/bootstrap.d.ts +1 -0
- package/dist-client/bootstrap.js +10 -0
- package/dist-client/bootstrap.js.map +1 -1
- package/dist-client/components/gallery-forms.js +1 -1
- package/dist-client/components/gallery-forms.js.map +1 -1
- package/dist-client/components/gallery-saver-setting-let.d.ts +11 -0
- package/dist-client/components/gallery-saver-setting-let.js +81 -0
- package/dist-client/components/gallery-saver-setting-let.js.map +1 -0
- package/dist-client/components/gallery-show.d.ts +46 -0
- package/dist-client/components/gallery-show.js +316 -0
- package/dist-client/components/gallery-show.js.map +1 -0
- package/dist-client/components/gallery-uploader.d.ts +5 -0
- package/dist-client/components/gallery-uploader.js +43 -4
- package/dist-client/components/gallery-uploader.js.map +1 -1
- package/dist-client/components/gallery-viewer.d.ts +2 -2
- package/dist-client/components/gallery-viewer.js +16 -3
- package/dist-client/components/gallery-viewer.js.map +1 -1
- package/dist-client/pages/gallery-page.d.ts +0 -1
- package/dist-client/pages/gallery-page.js +20 -16
- package/dist-client/pages/gallery-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/gallery-api.d.ts +4 -0
- package/dist-client/utils/gallery-api.js +1 -1
- package/dist-client/utils/gallery-api.js.map +1 -1
- package/dist-client/utils/gallery-saver.d.ts +6 -0
- package/dist-client/utils/gallery-saver.js +69 -0
- package/dist-client/utils/gallery-saver.js.map +1 -0
- package/dist-server/service/gallery/gallery-resolver.js +13 -2
- package/dist-server/service/gallery/gallery-resolver.js.map +1 -1
- package/dist-server/service/gallery/gallery-rules.d.ts +4 -2
- package/dist-server/service/gallery/gallery-rules.js +4 -3
- package/dist-server/service/gallery/gallery-rules.js.map +1 -1
- package/dist-server/service/gallery/gallery-types.d.ts +3 -0
- package/dist-server/service/gallery/gallery-types.js +12 -0
- package/dist-server/service/gallery/gallery-types.js.map +1 -1
- package/dist-server/service/gallery/gallery.d.ts +2 -1
- package/dist-server/service/gallery/gallery.js +7 -2
- package/dist-server/service/gallery/gallery.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -9
- package/server/service/gallery/gallery-resolver.ts +13 -4
- package/server/service/gallery/gallery-rules.ts +5 -4
- package/server/service/gallery/gallery-types.ts +9 -0
- package/server/service/gallery/gallery.ts +7 -2
- package/test/gallery-persistence.test.ts +24 -3
- package/test/gallery-rules.test.ts +10 -4
- package/test/refusals-and-bounds.test.ts +28 -0
- package/translations/en.json +16 -2
- package/translations/es.json +16 -2
- package/translations/ja.json +16 -2
- package/translations/ko.json +16 -2
- package/translations/ms.json +16 -2
- package/translations/zh.json +16 -2
package/client/bootstrap.ts
CHANGED
|
@@ -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="
|
|
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`<
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
436
|
-
this.
|
|
437
|
-
|
|
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'
|
|
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) {
|