@things-factory/announcement 0.0.0-stage → 10.1.222

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 (122) hide show
  1. package/client/announcement-watch.ts +109 -0
  2. package/client/bootstrap.ts +9 -0
  3. package/client/components/announcement-popup.ts +92 -0
  4. package/client/components/announcement-receipts.ts +196 -0
  5. package/client/components/announcement-sheet.ts +214 -0
  6. package/client/components/audience-picker.ts +221 -0
  7. package/client/index.ts +1 -0
  8. package/client/pages/announcement-editor.ts +505 -0
  9. package/client/pages/announcement-list.ts +200 -0
  10. package/client/pages/announcement-manage-list.ts +113 -0
  11. package/client/pages/announcement-view.ts +172 -0
  12. package/client/route.ts +17 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/announcement-api.ts +142 -0
  15. package/client/utils/announcement-badge.ts +46 -0
  16. package/client/utils/announcement-words.ts +26 -0
  17. package/dist-client/announcement-watch.d.ts +6 -0
  18. package/dist-client/announcement-watch.js +105 -0
  19. package/dist-client/announcement-watch.js.map +1 -0
  20. package/dist-client/bootstrap.d.ts +1 -0
  21. package/dist-client/bootstrap.js +8 -0
  22. package/dist-client/bootstrap.js.map +1 -0
  23. package/dist-client/components/announcement-popup.d.ts +20 -0
  24. package/dist-client/components/announcement-popup.js +98 -0
  25. package/dist-client/components/announcement-popup.js.map +1 -0
  26. package/dist-client/components/announcement-receipts.d.ts +30 -0
  27. package/dist-client/components/announcement-receipts.js +220 -0
  28. package/dist-client/components/announcement-receipts.js.map +1 -0
  29. package/dist-client/components/announcement-sheet.d.ts +17 -0
  30. package/dist-client/components/announcement-sheet.js +224 -0
  31. package/dist-client/components/announcement-sheet.js.map +1 -0
  32. package/dist-client/components/audience-picker.d.ts +32 -0
  33. package/dist-client/components/audience-picker.js +223 -0
  34. package/dist-client/components/audience-picker.js.map +1 -0
  35. package/dist-client/index.d.ts +1 -0
  36. package/dist-client/index.js +2 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/pages/announcement-editor.d.ts +59 -0
  39. package/dist-client/pages/announcement-editor.js +523 -0
  40. package/dist-client/pages/announcement-editor.js.map +1 -0
  41. package/dist-client/pages/announcement-list.d.ts +54 -0
  42. package/dist-client/pages/announcement-list.js +209 -0
  43. package/dist-client/pages/announcement-list.js.map +1 -0
  44. package/dist-client/pages/announcement-manage-list.d.ts +46 -0
  45. package/dist-client/pages/announcement-manage-list.js +115 -0
  46. package/dist-client/pages/announcement-manage-list.js.map +1 -0
  47. package/dist-client/pages/announcement-view.d.ts +30 -0
  48. package/dist-client/pages/announcement-view.js +182 -0
  49. package/dist-client/pages/announcement-view.js.map +1 -0
  50. package/dist-client/route.d.ts +1 -0
  51. package/dist-client/route.js +18 -0
  52. package/dist-client/route.js.map +1 -0
  53. package/dist-client/tsconfig.tsbuildinfo +1 -0
  54. package/dist-client/utils/announcement-api.d.ts +149 -0
  55. package/dist-client/utils/announcement-api.js +52 -0
  56. package/dist-client/utils/announcement-api.js.map +1 -0
  57. package/dist-client/utils/announcement-badge.d.ts +4 -0
  58. package/dist-client/utils/announcement-badge.js +47 -0
  59. package/dist-client/utils/announcement-badge.js.map +1 -0
  60. package/dist-client/utils/announcement-words.d.ts +11 -0
  61. package/dist-client/utils/announcement-words.js +23 -0
  62. package/dist-client/utils/announcement-words.js.map +1 -0
  63. package/dist-server/announcement-read.d.ts +6 -0
  64. package/dist-server/announcement-read.js +45 -0
  65. package/dist-server/announcement-read.js.map +1 -0
  66. package/dist-server/attachment-reader.d.ts +1 -0
  67. package/dist-server/attachment-reader.js +13 -0
  68. package/dist-server/attachment-reader.js.map +1 -0
  69. package/dist-server/index.d.ts +2 -0
  70. package/dist-server/index.js +20 -0
  71. package/dist-server/index.js.map +1 -0
  72. package/dist-server/role-tasks.d.ts +2 -0
  73. package/dist-server/role-tasks.js +13 -0
  74. package/dist-server/role-tasks.js.map +1 -0
  75. package/dist-server/service/announcement/announcement-resolver.d.ts +38 -0
  76. package/dist-server/service/announcement/announcement-resolver.js +664 -0
  77. package/dist-server/service/announcement/announcement-resolver.js.map +1 -0
  78. package/dist-server/service/announcement/announcement-rules.d.ts +150 -0
  79. package/dist-server/service/announcement/announcement-rules.js +227 -0
  80. package/dist-server/service/announcement/announcement-rules.js.map +1 -0
  81. package/dist-server/service/announcement/announcement-service.d.ts +54 -0
  82. package/dist-server/service/announcement/announcement-service.js +248 -0
  83. package/dist-server/service/announcement/announcement-service.js.map +1 -0
  84. package/dist-server/service/announcement/announcement-types.d.ts +54 -0
  85. package/dist-server/service/announcement/announcement-types.js +203 -0
  86. package/dist-server/service/announcement/announcement-types.js.map +1 -0
  87. package/dist-server/service/announcement/announcement.d.ts +124 -0
  88. package/dist-server/service/announcement/announcement.js +462 -0
  89. package/dist-server/service/announcement/announcement.js.map +1 -0
  90. package/dist-server/service/announcement/index.d.ts +4 -0
  91. package/dist-server/service/announcement/index.js +8 -0
  92. package/dist-server/service/announcement/index.js.map +1 -0
  93. package/dist-server/service/index.d.ts +7 -0
  94. package/dist-server/service/index.js +23 -0
  95. package/dist-server/service/index.js.map +1 -0
  96. package/dist-server/tsconfig.tsbuildinfo +1 -0
  97. package/package.json +51 -4
  98. package/server/announcement-read.ts +44 -0
  99. package/server/attachment-reader.ts +11 -0
  100. package/server/index.ts +16 -0
  101. package/server/role-tasks.ts +12 -0
  102. package/server/service/announcement/announcement-resolver.ts +506 -0
  103. package/server/service/announcement/announcement-rules.ts +239 -0
  104. package/server/service/announcement/announcement-service.ts +240 -0
  105. package/server/service/announcement/announcement-types.ts +138 -0
  106. package/server/service/announcement/announcement.ts +367 -0
  107. package/server/service/announcement/index.ts +5 -0
  108. package/server/service/index.ts +19 -0
  109. package/server/tsconfig.json +11 -0
  110. package/test/announcement-persistence.test.ts +271 -0
  111. package/test/announcement-read.test.ts +49 -0
  112. package/test/announcement-rules.test.ts +217 -0
  113. package/test/open-doors.test.ts +45 -0
  114. package/test/translations.test.ts +54 -0
  115. package/things-factory.config.js +13 -0
  116. package/translations/en.json +169 -0
  117. package/translations/es.json +169 -0
  118. package/translations/ja.json +169 -0
  119. package/translations/ko.json +169 -0
  120. package/translations/ms.json +169 -0
  121. package/translations/zh.json +169 -0
  122. package/README.md +0 -3
@@ -0,0 +1,505 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '@operato/input/ox-input-file.js'
3
+ import '@operato/list-state/ox-page-state.js'
4
+ import '../components/audience-picker.js'
5
+
6
+ import { css, html, nothing } from 'lit'
7
+ import { customElement, state } from 'lit/decorators.js'
8
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
9
+
10
+ import { i18next, localize } from '@operato/i18n'
11
+ import { OxPrompt } from '@operato/popup/ox-prompt.js'
12
+ import { notify } from '@operato/layout'
13
+ import { navigate, PageView } from '@operato/shell'
14
+ import { renderMarkdown } from '@things-factory/ai-assistant/dist-client/index.js'
15
+ import { ButtonStyles, DocumentPageStyles, FormFieldStyles } from '@operato/styles'
16
+
17
+ import { chooser } from '../components/audience-picker.js'
18
+ import { announcementApi, type AnnouncementInput, type AnnouncementItem, type AnnouncementLink, type Audience, type FileRow } from '../utils/announcement-api.js'
19
+ import { CATEGORIES, categoryWord, IMPORTANCES, importanceWord, reasonOf, statusWord, t } from '../utils/announcement-words.js'
20
+
21
+ type Form = {
22
+ title: string
23
+ body: string
24
+ category: string
25
+ importance: string
26
+ startsAt: string
27
+ endsAt: string
28
+ pinned: boolean
29
+ requiresAcknowledgement: boolean
30
+ acknowledgeBy: string
31
+ popup: boolean
32
+ sendEmail: boolean
33
+ links: AnnouncementLink[]
34
+ audiences: Audience[]
35
+ }
36
+
37
+ const blank = (): Form => ({
38
+ title: '',
39
+ body: '',
40
+ category: 'GENERAL',
41
+ importance: 'NORMAL',
42
+ startsAt: '',
43
+ endsAt: '',
44
+ pinned: false,
45
+ requiresAcknowledgement: false,
46
+ acknowledgeBy: '',
47
+ popup: false,
48
+ sendEmail: false,
49
+ links: [],
50
+ audiences: []
51
+ })
52
+
53
+ /** `datetime-local` speaks the reader's own clock. */
54
+ const pad = (n: number) => String(n).padStart(2, '0')
55
+ function localOf(value?: string | null, dateOnly = false): string {
56
+ if (!value) return ''
57
+ const d = new Date(value)
58
+ const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
59
+ return dateOnly ? day : `${day}T${pad(d.getHours())}:${pad(d.getMinutes())}`
60
+ }
61
+ const isoOf = (local: string, endOfDay = false) => (local ? new Date(endOfDay && local.length === 10 ? `${local}T23:59:59` : local).toISOString() : null)
62
+
63
+ function formOf(one: AnnouncementItem): Form {
64
+ return {
65
+ title: one.title,
66
+ body: one.body ?? '',
67
+ category: one.category,
68
+ importance: one.importance,
69
+ startsAt: localOf(one.startsAt),
70
+ endsAt: localOf(one.endsAt),
71
+ pinned: one.pinned,
72
+ requiresAcknowledgement: one.requiresAcknowledgement,
73
+ acknowledgeBy: localOf(one.acknowledgeBy, true),
74
+ popup: one.popup,
75
+ sendEmail: !!one.sendEmail,
76
+ links: (one.links ?? []).map(link => ({ ...link })),
77
+ audiences: (one.audiences ?? []).map(({ scope, targetId, targetName }) => ({ scope, targetId, targetName }))
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 공지 작성 — what it says, to whom, when, and what it asks of its readers (ADR-0163 decisions 2-5). A new one is kept as
83
+ * a draft until it is published; one already out is changed into a new version, and its writer may tell everyone again.
84
+ * `?from=<id>` starts a new one as a copy.
85
+ */
86
+ @customElement('announcement-editor')
87
+ export class AnnouncementEditor extends localize(i18next)(PageView) {
88
+ static styles = [
89
+ FormFieldStyles,
90
+ ButtonStyles,
91
+ DocumentPageStyles,
92
+ css`
93
+ .form {
94
+ display: grid;
95
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
96
+ gap: var(--spacing-medium, 8px) var(--spacing-large, 12px);
97
+ }
98
+ .form .wide {
99
+ grid-column: 1 / -1;
100
+ }
101
+ label {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 2px;
105
+ }
106
+ label.check {
107
+ flex-direction: row;
108
+ align-items: center;
109
+ gap: var(--spacing-small, 4px);
110
+ }
111
+ textarea.body {
112
+ display: block;
113
+ width: 100%;
114
+ box-sizing: border-box;
115
+ min-height: 240px;
116
+ font-family: inherit;
117
+ }
118
+ /* Writing or its preview - the segmented switch of the card library (ai-dashboard), right above the body. */
119
+ .tabs {
120
+ display: inline-flex;
121
+ gap: 4px;
122
+ padding: 3px;
123
+ margin-bottom: var(--spacing-small, 4px);
124
+ border-radius: 10px;
125
+ background: var(--ui-panel-2, var(--md-sys-color-surface-container));
126
+ }
127
+ .tabs button {
128
+ padding: 4px 14px;
129
+ border: 0;
130
+ border-radius: 8px;
131
+ background: transparent;
132
+ box-shadow: none;
133
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
134
+ font: inherit;
135
+ font-weight: 600;
136
+ cursor: pointer;
137
+ }
138
+ .tabs button[aria-selected='true'] {
139
+ background: var(--ui-panel, var(--md-sys-color-surface-container-lowest));
140
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
141
+ box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
142
+ }
143
+ .preview.markdown :is(ul, ol) {
144
+ padding-inline-start: 1.4em;
145
+ }
146
+ .preview {
147
+ min-height: 240px;
148
+ padding: var(--spacing-medium, 8px);
149
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
150
+ border-radius: var(--border-radius, 4px);
151
+ }
152
+ .hint {
153
+ font-size: var(--ui-t-small, 12px);
154
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
155
+ }
156
+ ul.files,
157
+ ul.links {
158
+ list-style: none;
159
+ margin: 0;
160
+ padding: 0;
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: var(--spacing-small, 4px);
164
+ }
165
+ ul.files li,
166
+ ul.links li {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: var(--spacing-small, 4px);
170
+ }
171
+ ul.links input {
172
+ flex: 1;
173
+ }
174
+ ul.links input.label {
175
+ flex: 0 1 200px;
176
+ }
177
+ .removed {
178
+ text-decoration: line-through;
179
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
180
+ }
181
+ .add-link {
182
+ align-self: flex-start;
183
+ }
184
+ li button,
185
+ .add-link {
186
+ border: 0;
187
+ background: none;
188
+ cursor: pointer;
189
+ color: var(--md-sys-color-primary);
190
+ display: inline-flex;
191
+ align-items: center;
192
+ gap: 2px;
193
+ font: inherit;
194
+ padding: 0;
195
+ }
196
+ li button md-icon,
197
+ .add-link md-icon {
198
+ --md-icon-size: 18px;
199
+ }
200
+ `
201
+ ]
202
+
203
+ @state() announcement: AnnouncementItem | null = null
204
+ @state() form: Form = blank()
205
+ @state() files: FileRow[] = []
206
+ @state() added: File[] = []
207
+ @state() removed: string[] = []
208
+ @state() notifyAgain = false
209
+ @state() preview = false
210
+ @state() progress?: number
211
+ @state() loading = false
212
+ @state() error: unknown = null
213
+ @state() busy = false
214
+
215
+ get out() {
216
+ return this.announcement?.status === 'PUBLISHED'
217
+ }
218
+
219
+ get context() {
220
+ const one = this.announcement
221
+ const actions: any[] = []
222
+ const draft = !one || one.status === 'DRAFT'
223
+ if (!this.busy && !this.error && (!one || one.editable)) {
224
+ if (draft && one) actions.push({ title: t('announcement.button.delete'), icon: 'delete', action: () => this.deleteDraft() })
225
+ actions.push({ title: draft ? t('announcement.button.save-draft') : t('announcement.button.save'), icon: 'save', action: () => this.save(false) })
226
+ if (draft) {
227
+ const later = !!this.form.startsAt && new Date(this.form.startsAt).getTime() > Date.now()
228
+ actions.push({ title: later ? t('announcement.button.schedule') : t('announcement.button.publish'), icon: 'campaign', emphasis: { raised: true }, action: () => this.save(true) })
229
+ }
230
+ }
231
+ return {
232
+ title: one ? `${t('announcement.title.edit')} · ${one.title}` : t('announcement.title.new'),
233
+ ...(one ? { description: statusWord(one.status) } : {}),
234
+ frame: 'document',
235
+ /* Opened from 공지사항 or 공지 관리; the menu marks 공지사항. */
236
+ menu: 'announcement-list',
237
+ actions
238
+ }
239
+ }
240
+
241
+ async pageUpdated(changes: any, lifecycle: any) {
242
+ if (!this.active || !(changes.active || changes.params)) return
243
+ const { id, from } = lifecycle?.params ?? {}
244
+ if (id) {
245
+ if (id !== this.announcement?.id) await this.load(id, false)
246
+ } else if (from) {
247
+ await this.load(from, true)
248
+ } else {
249
+ this.announcement = null
250
+ this.form = blank()
251
+ this.files = []
252
+ this.reset()
253
+ this.updateContext()
254
+ }
255
+ }
256
+
257
+ private reset() {
258
+ this.added = []
259
+ this.removed = []
260
+ this.notifyAgain = false
261
+ this.preview = false
262
+ }
263
+
264
+ private async load(id: string, asCopy: boolean) {
265
+ this.loading = true
266
+ this.error = null
267
+ try {
268
+ const one = await announcementApi.one(id)
269
+ if (!one) throw new Error(t('announcement.refusal.unknown-announcement'))
270
+ this.announcement = asCopy ? null : one
271
+ this.form = asCopy ? { ...formOf(one), startsAt: '', endsAt: '' } : formOf(one)
272
+ this.files = asCopy ? [] : (one.attachments ?? [])
273
+ this.reset()
274
+ } catch (error) {
275
+ this.error = error
276
+ } finally {
277
+ this.loading = false
278
+ this.updateContext()
279
+ }
280
+ }
281
+
282
+ render() {
283
+ const form = this.form
284
+ const set = (patch: Partial<Form>) => {
285
+ this.form = { ...form, ...patch }
286
+ this.updateContext()
287
+ }
288
+ const editable = !this.busy && (!this.announcement || !!this.announcement.editable)
289
+ return html`<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => this.announcement && this.load(this.announcement.id, false)}>
290
+ <div class="page">
291
+ <section class="page-section">
292
+ <header class="page-section-header"><h3>${t('announcement.text.section-what')}</h3></header>
293
+ <div class="form">
294
+ <label class="wide"
295
+ >${t('announcement.field.title')}
296
+ <input .value=${form.title} maxlength="200" ?disabled=${!editable} @input=${(e: any) => set({ title: e.target.value })}
297
+ /></label>
298
+ <label
299
+ >${t('announcement.field.category')}
300
+ ${chooser(
301
+ CATEGORIES.map(code => ({ value: code, display: categoryWord(code) })),
302
+ form.category,
303
+ value => set({ category: value }),
304
+ '—',
305
+ t('announcement.field.category')
306
+ )}
307
+ </label>
308
+ <label
309
+ >${t('announcement.field.importance')}
310
+ ${chooser(
311
+ IMPORTANCES.map(code => ({ value: code, display: importanceWord(code) })),
312
+ form.importance,
313
+ /* An urgent one pops up unless its writer says not to. */
314
+ value => set({ importance: value, ...(value === 'URGENT' ? { popup: true } : {}) }),
315
+ '—',
316
+ t('announcement.field.importance')
317
+ )}
318
+ </label>
319
+ <div class="wide">
320
+ <div class="tabs" role="tablist">
321
+ <button role="tab" aria-selected=${String(!this.preview)} @click=${() => (this.preview = false)}>${t('announcement.text.write')}</button>
322
+ <button role="tab" aria-selected=${String(this.preview)} @click=${() => (this.preview = true)}>${t('announcement.text.preview')}</button>
323
+ </div>
324
+ ${this.preview
325
+ ? html`<div class="preview markdown">${unsafeHTML(renderMarkdown(form.body))}</div>`
326
+ : html`<textarea class="body" .value=${form.body} ?disabled=${!editable} placeholder=${t('announcement.text.body-placeholder')} @input=${(e: any) => set({ body: e.target.value })}></textarea>
327
+ <span class="hint">${t('announcement.text.markdown-hint')}</span>`}
328
+ </div>
329
+ </div>
330
+ </section>
331
+
332
+ <section class="page-section">
333
+ <header class="page-section-header"><h3>${t('announcement.field.audience')}</h3></header>
334
+ <announcement-audience-picker .value=${form.audiences} ?disabled=${!editable} @change=${(e: CustomEvent) => set({ audiences: e.detail })}></announcement-audience-picker>
335
+ </section>
336
+
337
+ <section class="page-section">
338
+ <header class="page-section-header"><h3>${t('announcement.text.section-when')}</h3></header>
339
+ <div class="form">
340
+ <label
341
+ >${t('announcement.field.starts-at')}
342
+ <input type="datetime-local" .value=${form.startsAt} ?disabled=${!editable || this.out} @input=${(e: any) => set({ startsAt: e.target.value })} />
343
+ <span class="hint">${t('announcement.text.starts-at-hint')}</span>
344
+ </label>
345
+ <label
346
+ >${t('announcement.field.ends-at')}
347
+ <input type="datetime-local" .value=${form.endsAt} ?disabled=${!editable} @input=${(e: any) => set({ endsAt: e.target.value })} />
348
+ <span class="hint">${t('announcement.text.ends-at-hint')}</span>
349
+ </label>
350
+ <label class="check wide"><input type="checkbox" .checked=${form.pinned} ?disabled=${!editable} @change=${(e: any) => set({ pinned: e.target.checked })} />${t('announcement.text.pinned-label')}</label>
351
+ <label class="check wide"
352
+ ><input
353
+ type="checkbox"
354
+ .checked=${form.requiresAcknowledgement}
355
+ ?disabled=${!editable}
356
+ @change=${(e: any) => set({ requiresAcknowledgement: e.target.checked, ...(e.target.checked ? { popup: true } : { acknowledgeBy: '' }) })}
357
+ />${t('announcement.text.acknowledge-label')}</label
358
+ >
359
+ ${form.requiresAcknowledgement
360
+ ? html`<label
361
+ >${t('announcement.field.acknowledge-by')}
362
+ <input type="date" .value=${form.acknowledgeBy} ?disabled=${!editable} @input=${(e: any) => set({ acknowledgeBy: e.target.value })}
363
+ /></label>`
364
+ : nothing}
365
+ <label class="check wide"><input type="checkbox" .checked=${form.popup} ?disabled=${!editable} @change=${(e: any) => set({ popup: e.target.checked })} />${t('announcement.text.popup-label')}</label>
366
+ <label class="check wide"
367
+ ><input type="checkbox" .checked=${form.sendEmail} ?disabled=${!editable} @change=${(e: any) => set({ sendEmail: e.target.checked })} />${t('announcement.text.email-label')}</label
368
+ >
369
+ ${form.sendEmail ? html`<span class="hint wide">${t('announcement.text.email-hint')}</span>` : nothing}
370
+ </div>
371
+ </section>
372
+
373
+ <section class="page-section">
374
+ <header class="page-section-header"><h3>${t('announcement.field.attachments')}</h3></header>
375
+ <ul class="files">
376
+ ${this.files.map(
377
+ file => html`<li class=${this.removed.includes(file.id) ? 'removed' : ''}>
378
+ <md-icon>attach_file</md-icon>${file.name}
379
+ ${editable
380
+ ? this.removed.includes(file.id)
381
+ ? html`<button @click=${() => (this.removed = this.removed.filter(id => id !== file.id))}><md-icon>undo</md-icon></button>`
382
+ : html`<button aria-label=${t('announcement.button.remove')} @click=${() => (this.removed = [...this.removed, file.id])}><md-icon>close</md-icon></button>`
383
+ : nothing}
384
+ </li>`
385
+ )}
386
+ ${this.added.map(
387
+ (file, index) => html`<li>
388
+ <md-icon>upload_file</md-icon>${file.name}
389
+ <button aria-label=${t('announcement.button.remove')} @click=${() => (this.added = this.added.filter((_, i) => i !== index))}><md-icon>close</md-icon></button>
390
+ </li>`
391
+ )}
392
+ </ul>
393
+ ${editable ? html`<ox-input-file quiet hide-filelist multiple icon="attach_file" .progress=${this.progress} @change=${(e: CustomEvent) => this.pick(e)}></ox-input-file>` : nothing}
394
+ </section>
395
+
396
+ <section class="page-section">
397
+ <header class="page-section-header"><h3>${t('announcement.field.links')}</h3></header>
398
+ <ul class="links">
399
+ ${form.links.map(
400
+ (link, index) => html`<li>
401
+ <input
402
+ class="label"
403
+ .value=${link.label}
404
+ maxlength="100"
405
+ placeholder=${t('announcement.text.link-label')}
406
+ ?disabled=${!editable}
407
+ @input=${(e: any) => set({ links: form.links.map((one, i) => (i === index ? { ...one, label: e.target.value } : one)) })}
408
+ />
409
+ <input
410
+ .value=${link.url}
411
+ placeholder=${t('announcement.text.link-url')}
412
+ ?disabled=${!editable}
413
+ @input=${(e: any) => set({ links: form.links.map((one, i) => (i === index ? { ...one, url: e.target.value } : one)) })}
414
+ />
415
+ ${editable ? html`<button aria-label=${t('announcement.button.remove')} @click=${() => set({ links: form.links.filter((_, i) => i !== index) })}><md-icon>close</md-icon></button>` : nothing}
416
+ </li>`
417
+ )}
418
+ </ul>
419
+ ${editable && form.links.length < 10
420
+ ? html`<button class="add-link" @click=${() => set({ links: [...form.links, { label: '', url: '' }] })}><md-icon>add_link</md-icon>${t('announcement.button.add-link')}</button>
421
+ <span class="hint">${t('announcement.text.link-hint')}</span>`
422
+ : nothing}
423
+ </section>
424
+
425
+ ${this.out
426
+ ? html`<section class="page-section">
427
+ <label class="check"><input type="checkbox" .checked=${this.notifyAgain} @change=${(e: any) => (this.notifyAgain = e.target.checked)} />${t('announcement.text.notify-again')}</label>
428
+ <span class="hint">${t('announcement.text.new-version', { version: (this.announcement?.version ?? 1) + 1 })}</span>
429
+ </section>`
430
+ : nothing}
431
+ </div>
432
+ </ox-page-state>`
433
+ }
434
+
435
+ private pick(e: CustomEvent) {
436
+ const picked = [...((e.detail as File[]) ?? [])]
437
+ /* The picker's own reset says `change` again, with nothing - that one is let go. */
438
+ if (!picked.length) return
439
+ ;(e.target as any).reset?.()
440
+ this.added = [...this.added, ...picked]
441
+ }
442
+
443
+ private input(): AnnouncementInput {
444
+ const form = this.form
445
+ return {
446
+ title: form.title,
447
+ body: form.body,
448
+ category: form.category,
449
+ importance: form.importance,
450
+ startsAt: isoOf(form.startsAt),
451
+ endsAt: isoOf(form.endsAt),
452
+ pinned: form.pinned,
453
+ requiresAcknowledgement: form.requiresAcknowledgement,
454
+ acknowledgeBy: form.requiresAcknowledgement ? isoOf(form.acknowledgeBy, true) : null,
455
+ popup: form.popup,
456
+ sendEmail: form.sendEmail,
457
+ links: form.links.filter(link => link.label.trim() || link.url.trim()),
458
+ audiences: form.audiences.map(({ scope, targetId, targetName }) => ({ scope, targetId, targetName: targetName ?? undefined }))
459
+ }
460
+ }
461
+
462
+ /** Keeps it, then sends it out when asked - a draft that cannot go out stays kept with the reason told. */
463
+ private async save(publish: boolean) {
464
+ this.busy = true
465
+ this.updateContext()
466
+ const uploading = (fraction: number) => (this.progress = fraction)
467
+ try {
468
+ let id = this.announcement?.id
469
+ if (id) await announcementApi.update(id, this.input(), this.added, this.removed, this.notifyAgain, uploading)
470
+ else id = (await announcementApi.create(this.input(), this.added, uploading)).id
471
+ if (publish) {
472
+ const sent = await announcementApi.publish(id)
473
+ notify({ message: sent.status === 'SCHEDULED' ? t('announcement.text.scheduled-done') : t('announcement.text.published-done') })
474
+ navigate(`announcement-view?id=${id}`)
475
+ return
476
+ }
477
+ notify({ message: t('announcement.text.saved') })
478
+ if (this.announcement?.id === id) await this.load(id, false)
479
+ else navigate(`announcement-editor?id=${id}`)
480
+ } catch (error) {
481
+ notify({ level: 'error', message: reasonOf(error) })
482
+ } finally {
483
+ this.progress = undefined
484
+ this.busy = false
485
+ this.updateContext()
486
+ }
487
+ }
488
+
489
+ private async deleteDraft() {
490
+ const sure = await OxPrompt.open({
491
+ title: t('announcement.button.delete'),
492
+ text: t('announcement.text.delete-confirm', { title: this.announcement!.title }),
493
+ confirmButton: { text: t('announcement.button.delete') },
494
+ cancelButton: { text: t('announcement.button.cancel') }
495
+ })
496
+ if (!sure) return
497
+ try {
498
+ await announcementApi.remove(this.announcement!.id)
499
+ notify({ message: t('announcement.text.deleted') })
500
+ navigate('announcement-manage-list')
501
+ } catch (error) {
502
+ notify({ level: 'error', message: reasonOf(error) })
503
+ }
504
+ }
505
+ }