@blogmcp/community 0.0.0-stage → 0.1.0

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-DzjwnHyx.js +4597 -0
  5. package/dist/core/attrs.d.ts +73 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/client.d.ts +27 -0
  8. package/dist/core/discussion.d.ts +144 -0
  9. package/dist/core/download.d.ts +4 -0
  10. package/dist/core/format.d.ts +17 -0
  11. package/dist/core/i18n.d.ts +28 -0
  12. package/dist/core/names.d.ts +15 -0
  13. package/dist/core/notice.d.ts +50 -0
  14. package/dist/core/onboarding.d.ts +52 -0
  15. package/dist/core/outcomes.d.ts +28 -0
  16. package/dist/core/panel.d.ts +80 -0
  17. package/dist/core/receipt.d.ts +5 -0
  18. package/dist/core/text.d.ts +22 -0
  19. package/dist/core/turnstile.d.ts +33 -0
  20. package/dist/element.d.ts +26 -0
  21. package/dist/global.d.ts +8 -0
  22. package/dist/index.d.ts +25 -0
  23. package/dist/index.js +22 -0
  24. package/dist/mount.d.ts +65 -0
  25. package/dist/react.d.ts +17 -0
  26. package/dist/react.js +35 -0
  27. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  28. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  29. package/dist/ui/DiscussionView.vue.d.ts +33 -0
  30. package/dist/ui/LinkedText.vue.d.ts +5 -0
  31. package/dist/ui/MessageComposer.vue.d.ts +24 -0
  32. package/dist/ui/MessageItem.vue.d.ts +6 -0
  33. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  34. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  35. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  36. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  37. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  38. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  39. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  40. package/dist/ui/StatementView.vue.d.ts +15 -0
  41. package/dist/ui/context.d.ts +21 -0
  42. package/dist/ui/discussion-context.d.ts +19 -0
  43. package/dist/ui/types.d.ts +7 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/vue.d.ts +89 -0
  46. package/dist/vue.js +97 -0
  47. package/package.json +80 -4
  48. package/src/core/attrs.ts +147 -0
  49. package/src/core/avatars.ts +44 -0
  50. package/src/core/client.ts +54 -0
  51. package/src/core/discussion.ts +691 -0
  52. package/src/core/download.ts +21 -0
  53. package/src/core/format.ts +90 -0
  54. package/src/core/i18n.ts +49 -0
  55. package/src/core/names.ts +51 -0
  56. package/src/core/notice.ts +178 -0
  57. package/src/core/onboarding.ts +345 -0
  58. package/src/core/outcomes.ts +74 -0
  59. package/src/core/panel.ts +411 -0
  60. package/src/core/receipt.ts +28 -0
  61. package/src/core/text.ts +142 -0
  62. package/src/core/turnstile.ts +98 -0
  63. package/src/element.ts +109 -0
  64. package/src/global.ts +12 -0
  65. package/src/index.ts +35 -0
  66. package/src/mount.ts +127 -0
  67. package/src/react.ts +53 -0
  68. package/src/shims.d.ts +11 -0
  69. package/src/ui/AuthorAvatar.vue +26 -0
  70. package/src/ui/ConfirmDialog.vue +38 -0
  71. package/src/ui/DiscussionView.vue +294 -0
  72. package/src/ui/LinkedText.vue +18 -0
  73. package/src/ui/MessageComposer.vue +130 -0
  74. package/src/ui/MessageItem.vue +205 -0
  75. package/src/ui/MessageMenu.vue +67 -0
  76. package/src/ui/MyCommunityView.vue +369 -0
  77. package/src/ui/NoticeFormView.vue +244 -0
  78. package/src/ui/NoticeStatusView.vue +82 -0
  79. package/src/ui/OnboardingSteps.vue +163 -0
  80. package/src/ui/ReactionBar.vue +41 -0
  81. package/src/ui/ReportDialog.vue +88 -0
  82. package/src/ui/StatementView.vue +141 -0
  83. package/src/ui/context.ts +75 -0
  84. package/src/ui/discussion-context.ts +30 -0
  85. package/src/ui/styles.css +243 -0
  86. package/src/ui/types.ts +7 -0
  87. package/src/version.ts +3 -0
  88. package/src/vue.ts +116 -0
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A message's „Další akce": a disclosure of real buttons (and the notice page's link for a reader without a
4
+ * session). Esc or a click elsewhere closes it; the focus goes back to its toggle before an action runs,
5
+ * so a dialog the action opens returns it there.
6
+ */
7
+ import { nextTick, onBeforeUnmount, ref } from 'vue'
8
+ import { uid, useText } from './context'
9
+ import type { MenuItem } from './types'
10
+
11
+ defineProps<{ items: MenuItem[] }>()
12
+ const emit = defineEmits<{ select: [id: string] }>()
13
+ const { t } = useText()
14
+
15
+ const open = ref(false)
16
+ const toggle = ref<HTMLButtonElement | null>(null)
17
+ const root = ref<HTMLElement | null>(null)
18
+ const listId = uid('bcm-menu')
19
+
20
+ function onOutside(e: Event): void {
21
+ if (!root.value || e.composedPath().includes(root.value)) return
22
+ close(false)
23
+ }
24
+
25
+ function setOpen(next: boolean): void {
26
+ open.value = next
27
+ if (next) {
28
+ document.addEventListener('pointerdown', onOutside, true)
29
+ void nextTick(() => root.value?.querySelector<HTMLElement>('.bcm-menu-list button, .bcm-menu-list a')?.focus())
30
+ } else {
31
+ document.removeEventListener('pointerdown', onOutside, true)
32
+ }
33
+ }
34
+
35
+ function close(focusToggle = true): void {
36
+ setOpen(false)
37
+ if (focusToggle) toggle.value?.focus()
38
+ }
39
+
40
+ function pick(id: string): void {
41
+ close(true)
42
+ emit('select', id)
43
+ }
44
+
45
+ onBeforeUnmount(() => document.removeEventListener('pointerdown', onOutside, true))
46
+ </script>
47
+
48
+ <template>
49
+ <div v-if="items.length" ref="root" class="bcm-menu" @keydown.esc.stop="close(true)">
50
+ <button
51
+ ref="toggle"
52
+ type="button"
53
+ class="bcm-quiet"
54
+ :aria-expanded="open"
55
+ :aria-controls="listId"
56
+ @click="setOpen(!open)"
57
+ >
58
+ {{ t('discussion.menu') }}
59
+ </button>
60
+ <div v-if="open" :id="listId" class="bcm-menu-list">
61
+ <template v-for="item in items" :key="item.id">
62
+ <a v-if="item.href" :href="item.href" :class="{ 'bcm-danger': item.danger }" @click="close(false)">{{ item.label }}</a>
63
+ <button v-else type="button" :class="{ 'bcm-danger': item.danger }" @click="pick(item.id)">{{ item.label }}</button>
64
+ </template>
65
+ </div>
66
+ </div>
67
+ </template>
@@ -0,0 +1,369 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-my-community>`: the reader's „Moje komunita" (m1-contracts.md §10.4; community.md §5). Three
4
+ * pages in one element:
5
+ * - with `decision-id`: that statement of reasons and its appeal (the host's statement page);
6
+ * - with `#t=<receipt>` in the address: that notice's status and appeal — no sign-in needed; the receipt
7
+ * is read from this page's own address once and cleared from the address bar (core/receipt.ts);
8
+ * - otherwise the panel: profile, notifications, statements, reports, blocks, consents, export, deletion.
9
+ */
10
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
11
+ import {
12
+ COMMUNITY_HANDLE_CHANGE_DAYS,
13
+ signInLink,
14
+ statementPath,
15
+ type CommunityMessageKey,
16
+ type CommunityTransport,
17
+ type NotificationItem,
18
+ } from '@blogmcp/sdk/community'
19
+ import { AVATAR_PRESETS } from '../core/avatars'
20
+ import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
21
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
22
+ import { communityClientFor, siteBaseFor } from '../core/client'
23
+ import { exportFileName, saveJson } from '../core/download'
24
+ import { formatDate } from '../core/format'
25
+ import { documentLang, knownKey, pickLang, type Lang } from '../core/i18n'
26
+ import { failureText } from '../core/outcomes'
27
+ import { createPanel, type PanelController } from '../core/panel'
28
+ import { receiptForElement } from '../core/receipt'
29
+ import AuthorAvatar from './AuthorAvatar.vue'
30
+ import ConfirmDialog from './ConfirmDialog.vue'
31
+ import NoticeStatusView from './NoticeStatusView.vue'
32
+ import StatementView from './StatementView.vue'
33
+ import { provideText, uid } from './context'
34
+
35
+ defineOptions({ inheritAttrs: false })
36
+
37
+ const props = defineProps<{
38
+ apiBase?: string
39
+ /** The path the site is served under (`/blog`): its proxy and its pages, as the defaults. */
40
+ siteBase?: string
41
+ lang?: string
42
+ /** Opens this statement (the host's statement page). */
43
+ decisionId?: string
44
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
45
+ routes?: string | Record<string, unknown>
46
+ /** The site's sign-in page, for a reader without a session. */
47
+ signInHref?: string
48
+ transport?: CommunityTransport
49
+ }>()
50
+
51
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
52
+ const { t, text } = provideText(lang)
53
+ const id = uid('bcm-panel')
54
+ const heading = ref<HTMLElement | null>(null)
55
+
56
+ const siteBase = siteBaseFor(props.siteBase)
57
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
58
+ const ctl: PanelController | null = client ? createPanel({ client, lang: lang.value }) : null
59
+ const state = ctl?.state ?? null
60
+
61
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
62
+ const decisionMode = computed(() => !!props.decisionId)
63
+ const noticeMode = ref(false)
64
+ const confirming = ref<'consent' | 'erase' | null>(null)
65
+
66
+ const host = useHost()
67
+
68
+ onMounted(() => {
69
+ if (!ctl) return
70
+ // Only a receipt this page's own address carried (or this element took before it was moved), never the tab's.
71
+ const receipt = receiptForElement(host)
72
+ if (receipt !== undefined) {
73
+ noticeMode.value = true
74
+ void ctl.loadNotice(receipt)
75
+ }
76
+ if (props.decisionId) void ctl.loadStatement(props.decisionId)
77
+ else void ctl.loadMember()
78
+ })
79
+ // A host that reuses the element for another statement (client-side navigation) reads that one.
80
+ watch(
81
+ () => props.decisionId,
82
+ (id, before) => {
83
+ if (!ctl || id === before) return
84
+ if (id) void ctl.loadStatement(id)
85
+ else if (state?.phase === 'idle') void ctl.loadMember()
86
+ },
87
+ )
88
+ onBeforeUnmount(() => ctl?.dispose())
89
+
90
+ const memberFailure = computed(() => {
91
+ const f = state?.failure
92
+ if (!f) return ''
93
+ const r = failureText(f, lang.value)
94
+ return t(r.key, r.params)
95
+ })
96
+ const statementFailure = computed(() => {
97
+ const f = state?.statement.failure
98
+ if (!f) return ''
99
+ const r = failureText(f, lang.value)
100
+ return t(r.key, r.params)
101
+ })
102
+ const noticeFailure = computed(() => {
103
+ const f = state?.notice.failure
104
+ if (!f) return ''
105
+ const r = failureText(f, lang.value)
106
+ return t(r.key, r.params)
107
+ })
108
+
109
+ function signInHref(): string | null {
110
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
111
+ }
112
+ const statementHref = (decisionId: string): string | null => statementPath(routes.value.statementPath, decisionId)
113
+ const notificationKey = (n: NotificationItem): CommunityMessageKey | null => knownKey(`notification.${n.code}`)
114
+ const restrictionKey = (code: string): CommunityMessageKey | null => knownKey(`restriction.${code}`)
115
+ const categoryKey = (code: string): CommunityMessageKey | null => knownKey(`category.${code}`)
116
+ const caseStatusKey = (code: string): CommunityMessageKey | null => knownKey(`caseStatus.${code}`)
117
+ const appealStatusKey = (decisionId: string): CommunityMessageKey | null => {
118
+ const a = state?.appeals.find((x) => x.decisionId === decisionId)
119
+ return a ? knownKey(`appealStatus.${a.status}`) : null
120
+ }
121
+
122
+ const consent = computed(() => state?.me?.acceptances.art9Consent ?? null)
123
+ const member = computed(() => state?.me?.member ?? null)
124
+ const profile = computed(() => state?.me?.profile ?? null)
125
+
126
+ function exportNow(): void {
127
+ void ctl?.exportData((data) => saveJson(data, exportFileName(t('panel.exportFileName'))))
128
+ }
129
+
130
+ async function confirmAction(): Promise<void> {
131
+ if (!ctl) return
132
+ if (confirming.value === 'consent') await ctl.withdrawConsent()
133
+ else if (confirming.value === 'erase') await ctl.erase()
134
+ confirming.value = null
135
+ }
136
+
137
+ // One live region for the outcomes (cleared in between, so the same one is announced again).
138
+ const liveText = ref('')
139
+ watch(
140
+ () => state?.announceId,
141
+ () => {
142
+ liveText.value = ''
143
+ const next = text(state?.announcement)
144
+ void nextTick(() => {
145
+ liveText.value = next
146
+ })
147
+ },
148
+ )
149
+ </script>
150
+
151
+ <template>
152
+ <section v-if="ctl && state" class="bcm bcm-panel" :lang="lang" :aria-labelledby="`${id}-title`">
153
+ <h2 :id="`${id}-title`" ref="heading" tabindex="-1">{{ decisionMode ? t('statement.title') : t('panel.title') }}</h2>
154
+
155
+ <!-- the notifier's part (#t=) -->
156
+ <template v-if="noticeMode">
157
+ <p v-if="state.notice.phase === 'missing'" class="bcm-note" role="alert">{{ t('notice.receiptMissing') }}</p>
158
+ <p v-else-if="state.notice.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
159
+ <p v-else-if="state.notice.phase === 'failed'" class="bcm-error" role="alert">{{ noticeFailure }}</p>
160
+ <NoticeStatusView
161
+ v-else-if="state.notice.value"
162
+ :notice="state.notice.value"
163
+ :appeal="state.notice.appeal"
164
+ :withdrawing="!!state.busy.noticeWithdraw"
165
+ @appeal="ctl.fileNoticeAppeal()"
166
+ @withdraw="ctl.withdrawNoticeAppeal()"
167
+ />
168
+ </template>
169
+
170
+ <!-- the statement page (decision-id) -->
171
+ <template v-if="decisionMode">
172
+ <p v-if="state.statement.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
173
+ <div v-else-if="state.statement.phase === 'signIn'" class="bcm-row">
174
+ <p>{{ t('panel.signIn') }}</p>
175
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
176
+ </div>
177
+ <p v-else-if="state.statement.phase === 'failed'" class="bcm-error" role="alert">{{ statementFailure }}</p>
178
+ <StatementView
179
+ v-else-if="state.statement.value"
180
+ :statement="state.statement.value"
181
+ :appeal="state.statement.appeal"
182
+ :withdrawing="!!state.busy.withdraw"
183
+ @appeal="ctl.fileAppeal()"
184
+ @withdraw="ctl.withdrawAppeal()"
185
+ />
186
+ <p><a :href="routes.myCommunityPath">{{ t('common.back') }}</a></p>
187
+ </template>
188
+
189
+ <!-- the panel -->
190
+ <template v-else>
191
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
192
+ <div v-else-if="state.phase === 'signIn' && !noticeMode" class="bcm-row">
193
+ <p>{{ t('panel.signIn') }}</p>
194
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
195
+ </div>
196
+ <p v-else-if="state.phase === 'failed'" class="bcm-error" role="alert">{{ memberFailure }}</p>
197
+ <template v-else-if="state.phase === 'ready'">
198
+ <!-- profile -->
199
+ <section v-if="profile && member" class="bcm-section" :aria-labelledby="`${id}-profile`">
200
+ <h3 :id="`${id}-profile`">{{ t('panel.profile') }}</h3>
201
+ <div v-if="!state.profile" class="bcm-msg">
202
+ <AuthorAvatar :author="{ kind: 'member', memberId: member.memberId, displayName: profile.displayName, avatarPreset: profile.avatarPreset }" />
203
+ <div class="bcm-msg-main">
204
+ <span class="bcm-author">{{ profile.displayName }}</span>
205
+ <span class="bcm-handle">{{ profile.handle }}</span>
206
+ <div class="bcm-row">
207
+ <button type="button" class="bcm-btn bcm-small" @click="ctl.editProfile()">{{ t('panel.profileEdit') }}</button>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ <form v-else class="bcm-form bcm-card" novalidate @submit.prevent="ctl.saveProfile()">
212
+ <div class="bcm-field">
213
+ <label :for="`${id}-name`">{{ t('onboarding.displayName') }}</label>
214
+ <input :id="`${id}-name`" v-model="state.profile.displayName" class="bcm-input" autocomplete="nickname" :aria-invalid="state.profile.fields.includes('displayName')" />
215
+ <p class="bcm-hint">{{ t('onboarding.displayNameHint') }}</p>
216
+ </div>
217
+ <div class="bcm-field">
218
+ <label :for="`${id}-handle`">{{ t('onboarding.handle') }}</label>
219
+ <input :id="`${id}-handle`" v-model="state.profile.handle" class="bcm-input" autocomplete="username" autocapitalize="none" spellcheck="false" :maxlength="HANDLE_MAX" :aria-invalid="state.profile.fields.includes('handle')" />
220
+ <p class="bcm-hint">{{ t('onboarding.handleHint', { min: HANDLE_MIN, max: HANDLE_MAX, days: COMMUNITY_HANDLE_CHANGE_DAYS }) }}</p>
221
+ </div>
222
+ <fieldset class="bcm-choices">
223
+ <legend>{{ t('onboarding.avatar') }}</legend>
224
+ <label class="bcm-choice">
225
+ <input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="null" />
226
+ <span>{{ t('onboarding.avatarNone') }}</span>
227
+ </label>
228
+ <label v-for="preset in AVATAR_PRESETS" :key="preset.id" class="bcm-choice">
229
+ <input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="preset.id" />
230
+ <span>{{ preset.emoji }}</span>
231
+ </label>
232
+ </fieldset>
233
+ <p v-if="state.profile.failure" class="bcm-error" role="alert">{{ text(failureText(state.profile.failure, lang)) }}</p>
234
+ <div class="bcm-row">
235
+ <span class="bcm-spacer" />
236
+ <button type="button" class="bcm-btn" :disabled="state.profile.sending" @click="ctl.cancelProfile()">{{ t('composer.cancel') }}</button>
237
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="state.profile.sending">{{ state.profile.sending ? t('composer.sending') : t('composer.save') }}</button>
238
+ </div>
239
+ </form>
240
+ </section>
241
+
242
+ <!-- notifications -->
243
+ <section class="bcm-section" :aria-labelledby="`${id}-notifications`">
244
+ <div class="bcm-row">
245
+ <h3 :id="`${id}-notifications`">{{ t('panel.notifications') }}</h3>
246
+ <span v-if="ctl.unreadCount() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unreadCount() }) }}</span>
247
+ <span class="bcm-spacer" />
248
+ <button v-if="ctl.unreadCount() > 0" type="button" class="bcm-btn bcm-small" :disabled="!!state.busy.markRead" @click="ctl.markAllRead()">{{ t('panel.markRead') }}</button>
249
+ </div>
250
+ <p v-if="state.notifications.length === 0" class="bcm-muted">{{ t('panel.notificationsEmpty') }}</p>
251
+ <ul v-else class="bcm-list">
252
+ <li v-for="n in state.notifications" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
253
+ <p v-if="notificationKey(n)">{{ t(notificationKey(n)!) }}</p>
254
+ <time class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
255
+ <a v-if="n.decisionId && statementHref(n.decisionId)" :href="statementHref(n.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
256
+ </li>
257
+ </ul>
258
+ </section>
259
+
260
+ <!-- statements -->
261
+ <section class="bcm-section" :aria-labelledby="`${id}-statements`">
262
+ <h3 :id="`${id}-statements`">{{ t('panel.statements') }}</h3>
263
+ <p v-if="state.decisions.length === 0" class="bcm-muted">{{ t('panel.statementsEmpty') }}</p>
264
+ <ul v-else class="bcm-list">
265
+ <li v-for="d in state.decisions" :key="d.decisionId">
266
+ <p v-if="restrictionKey(d.restriction)"><strong>{{ t(restrictionKey(d.restriction)!) }}</strong></p>
267
+ <p v-if="categoryKey(d.category)" class="bcm-muted">{{ t(categoryKey(d.category)!) }}</p>
268
+ <p class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, d.decidedAt) }) }}</p>
269
+ <p v-if="appealStatusKey(d.decisionId)" class="bcm-muted">{{ t(appealStatusKey(d.decisionId)!) }}</p>
270
+ <p v-else-if="d.canAppeal" class="bcm-muted">{{ t('statement.appealUntil', { date: formatDate(lang, d.appealWindowClosesAt) }) }}</p>
271
+ <a v-if="statementHref(d.decisionId)" :href="statementHref(d.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
272
+ </li>
273
+ </ul>
274
+ </section>
275
+
276
+ <!-- reports -->
277
+ <section class="bcm-section" :aria-labelledby="`${id}-reports`">
278
+ <h3 :id="`${id}-reports`">{{ t('panel.reports') }}</h3>
279
+ <p v-if="state.reports.length === 0" class="bcm-muted">{{ t('panel.reportsEmpty') }}</p>
280
+ <ul v-else class="bcm-list">
281
+ <li v-for="r in state.reports" :key="r.caseId">
282
+ <p v-if="categoryKey(r.category)"><strong>{{ t(categoryKey(r.category)!) }}</strong></p>
283
+ <p v-if="caseStatusKey(r.status)">{{ t(caseStatusKey(r.status)!) }}</p>
284
+ <time class="bcm-time" :datetime="r.reportedAt">{{ formatDate(lang, r.reportedAt) }}</time>
285
+ <p v-if="r.decidedAt" class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, r.decidedAt) }) }}</p>
286
+ </li>
287
+ </ul>
288
+ </section>
289
+
290
+ <!-- blocks -->
291
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-blocks`">
292
+ <h3 :id="`${id}-blocks`">{{ t('panel.blocks') }}</h3>
293
+ <p v-if="state.blocks.length === 0" class="bcm-muted">{{ t('panel.blocksEmpty') }}</p>
294
+ <ul v-else class="bcm-list">
295
+ <li v-for="b in state.blocks" :key="b.memberId" class="bcm-row">
296
+ <span>{{ b.author.kind === 'member' && 'displayName' in b.author && b.author.displayName ? b.author.displayName : t('author.deleted') }}</span>
297
+ <span class="bcm-spacer" />
298
+ <button type="button" class="bcm-btn bcm-small" :disabled="!!state.busy[`block:${b.memberId}`]" @click="ctl.unblock(b.memberId)">{{ t('discussion.unblock') }}</button>
299
+ </li>
300
+ </ul>
301
+ </section>
302
+
303
+ <!-- consents -->
304
+ <section v-if="member && consent !== null" class="bcm-section" :aria-labelledby="`${id}-consents`">
305
+ <h3 :id="`${id}-consents`">{{ t('panel.consents') }}</h3>
306
+ <template v-if="consent === 'accepted'">
307
+ <p>{{ t('panel.art9Given') }}</p>
308
+ <div class="bcm-row">
309
+ <button type="button" class="bcm-btn" :disabled="!!state.busy.consent" @click="confirming = 'consent'">{{ t('panel.art9Withdraw') }}</button>
310
+ </div>
311
+ </template>
312
+ <p v-else>{{ t('panel.art9NotGiven') }}</p>
313
+ </section>
314
+
315
+ <!-- export -->
316
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-export`">
317
+ <h3 :id="`${id}-export`">{{ t('panel.export') }}</h3>
318
+ <p class="bcm-hint">{{ t('panel.exportHint') }}</p>
319
+ <div class="bcm-row">
320
+ <button type="button" class="bcm-btn" :disabled="state.exporting" @click="exportNow()">{{ state.exporting ? t('panel.exporting') : t('panel.export') }}</button>
321
+ </div>
322
+ </section>
323
+
324
+ <!-- the profile's deletion -->
325
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-erase`">
326
+ <h3 :id="`${id}-erase`">{{ t('panel.erase') }}</h3>
327
+ <template v-if="ctl.erasing()">
328
+ <p>{{ state.purgeAfter ? t('panel.eraseScheduled', { date: formatDate(lang, state.purgeAfter) }) : t('notification.erasure_scheduled') }}</p>
329
+ <div class="bcm-row">
330
+ <button type="button" class="bcm-btn bcm-primary" :disabled="!!state.busy.erase" @click="ctl.cancelErase()">{{ t('panel.eraseCancel') }}</button>
331
+ </div>
332
+ </template>
333
+ <template v-else>
334
+ <p class="bcm-hint">{{ t('panel.eraseExplain') }}</p>
335
+ <div class="bcm-row">
336
+ <button type="button" class="bcm-btn bcm-danger" :disabled="!!state.busy.erase" @click="confirming = 'erase'">{{ t('panel.erase') }}</button>
337
+ </div>
338
+ </template>
339
+ </section>
340
+ </template>
341
+ </template>
342
+
343
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
344
+
345
+ <ConfirmDialog
346
+ v-if="confirming === 'consent'"
347
+ title="panel.art9WithdrawTitle"
348
+ explain="panel.art9WithdrawExplain"
349
+ confirm="panel.art9WithdrawConfirm"
350
+ cancel="panel.art9WithdrawCancel"
351
+ :busy="!!state.busy.consent"
352
+ :fallback-focus="() => heading"
353
+ @confirm="confirmAction()"
354
+ @cancel="confirming = null"
355
+ />
356
+ <ConfirmDialog
357
+ v-if="confirming === 'erase'"
358
+ title="panel.eraseTitle"
359
+ explain="panel.eraseExplain"
360
+ confirm="panel.eraseConfirm"
361
+ cancel="panel.eraseKeep"
362
+ danger
363
+ :busy="!!state.busy.erase"
364
+ :fallback-focus="() => heading"
365
+ @confirm="confirmAction()"
366
+ @cancel="confirming = null"
367
+ />
368
+ </section>
369
+ </template>