@blogmcp/community 0.0.0-stage → 0.2.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,378 @@
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 OnboardingSteps from './OnboardingSteps.vue'
33
+ import StatementView from './StatementView.vue'
34
+ import { provideText, uid } from './context'
35
+
36
+ defineOptions({ inheritAttrs: false })
37
+
38
+ const props = defineProps<{
39
+ apiBase?: string
40
+ /** The path the site is served under (`/blog`): its proxy and its pages, as the defaults. */
41
+ siteBase?: string
42
+ lang?: string
43
+ /** Opens this statement (the host's statement page). */
44
+ decisionId?: string
45
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
46
+ routes?: string | Record<string, unknown>
47
+ /** The site's sign-in page, for a reader without a session. */
48
+ signInHref?: string
49
+ transport?: CommunityTransport
50
+ }>()
51
+
52
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
53
+ const { t, text } = provideText(lang)
54
+ const id = uid('bcm-panel')
55
+ const heading = ref<HTMLElement | null>(null)
56
+
57
+ const siteBase = siteBaseFor(props.siteBase)
58
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
59
+ const ctl: PanelController | null = client ? createPanel({ client, lang: lang.value }) : null
60
+ const state = ctl?.state ?? null
61
+
62
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
63
+ const decisionMode = computed(() => !!props.decisionId)
64
+ const noticeMode = ref(false)
65
+ const confirming = ref<'consent' | 'erase' | null>(null)
66
+
67
+ const host = useHost()
68
+
69
+ onMounted(() => {
70
+ if (!ctl) return
71
+ // Only a receipt this page's own address carried (or this element took before it was moved), never the tab's.
72
+ const receipt = receiptForElement(host)
73
+ if (receipt !== undefined) {
74
+ noticeMode.value = true
75
+ void ctl.loadNotice(receipt)
76
+ }
77
+ if (props.decisionId) void ctl.loadStatement(props.decisionId)
78
+ else void ctl.loadMember()
79
+ })
80
+ // A host that reuses the element for another statement (client-side navigation) reads that one.
81
+ watch(
82
+ () => props.decisionId,
83
+ (id, before) => {
84
+ if (!ctl || id === before) return
85
+ if (id) void ctl.loadStatement(id)
86
+ else if (state?.phase === 'idle') void ctl.loadMember()
87
+ },
88
+ )
89
+ onBeforeUnmount(() => ctl?.dispose())
90
+
91
+ const memberFailure = computed(() => {
92
+ const f = state?.failure
93
+ if (!f) return ''
94
+ const r = failureText(f, lang.value)
95
+ return t(r.key, r.params)
96
+ })
97
+ const statementFailure = computed(() => {
98
+ const f = state?.statement.failure
99
+ if (!f) return ''
100
+ const r = failureText(f, lang.value)
101
+ return t(r.key, r.params)
102
+ })
103
+ const noticeFailure = computed(() => {
104
+ const f = state?.notice.failure
105
+ if (!f) return ''
106
+ const r = failureText(f, lang.value)
107
+ return t(r.key, r.params)
108
+ })
109
+
110
+ function signInHref(): string | null {
111
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
112
+ }
113
+ const statementHref = (decisionId: string): string | null => statementPath(routes.value.statementPath, decisionId)
114
+ const notificationKey = (n: NotificationItem): CommunityMessageKey | null => knownKey(`notification.${n.code}`)
115
+ const restrictionKey = (code: string): CommunityMessageKey | null => knownKey(`restriction.${code}`)
116
+ const categoryKey = (code: string): CommunityMessageKey | null => knownKey(`category.${code}`)
117
+ const caseStatusKey = (code: string): CommunityMessageKey | null => knownKey(`caseStatus.${code}`)
118
+ const appealStatusKey = (decisionId: string): CommunityMessageKey | null => {
119
+ const a = state?.appeals.find((x) => x.decisionId === decisionId)
120
+ return a ? knownKey(`appealStatus.${a.status}`) : null
121
+ }
122
+
123
+ const consent = computed(() => state?.me?.acceptances.art9Consent ?? null)
124
+ const consentRefused = computed(() => state?.me?.acceptances.art9Refused === true)
125
+ const member = computed(() => state?.me?.member ?? null)
126
+ const profile = computed(() => state?.me?.profile ?? null)
127
+
128
+ function exportNow(): void {
129
+ void ctl?.exportData((data) => saveJson(data, exportFileName(t('panel.exportFileName'))))
130
+ }
131
+
132
+ async function confirmAction(): Promise<void> {
133
+ if (!ctl) return
134
+ if (confirming.value === 'consent') await ctl.withdrawConsent()
135
+ else if (confirming.value === 'erase') await ctl.erase()
136
+ confirming.value = null
137
+ }
138
+
139
+ // One live region for the outcomes (cleared in between, so the same one is announced again).
140
+ const liveText = ref('')
141
+ watch(
142
+ () => state?.announceId,
143
+ () => {
144
+ liveText.value = ''
145
+ const next = text(state?.announcement)
146
+ void nextTick(() => {
147
+ liveText.value = next
148
+ })
149
+ },
150
+ )
151
+ </script>
152
+
153
+ <template>
154
+ <section v-if="ctl && state" class="bcm bcm-panel" :lang="lang" :aria-labelledby="`${id}-title`">
155
+ <h2 :id="`${id}-title`" ref="heading" tabindex="-1">{{ decisionMode ? t('statement.title') : t('panel.title') }}</h2>
156
+
157
+ <!-- the notifier's part (#t=) -->
158
+ <template v-if="noticeMode">
159
+ <p v-if="state.notice.phase === 'missing'" class="bcm-note" role="alert">{{ t('notice.receiptMissing') }}</p>
160
+ <p v-else-if="state.notice.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
161
+ <p v-else-if="state.notice.phase === 'failed'" class="bcm-error" role="alert">{{ noticeFailure }}</p>
162
+ <NoticeStatusView
163
+ v-else-if="state.notice.value"
164
+ :notice="state.notice.value"
165
+ :appeal="state.notice.appeal"
166
+ :withdrawing="!!state.busy.noticeWithdraw"
167
+ @appeal="ctl.fileNoticeAppeal()"
168
+ @withdraw="ctl.withdrawNoticeAppeal()"
169
+ />
170
+ </template>
171
+
172
+ <!-- the statement page (decision-id) -->
173
+ <template v-if="decisionMode">
174
+ <p v-if="state.statement.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
175
+ <div v-else-if="state.statement.phase === 'signIn'" class="bcm-row">
176
+ <p>{{ t('panel.signIn') }}</p>
177
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
178
+ </div>
179
+ <p v-else-if="state.statement.phase === 'failed'" class="bcm-error" role="alert">{{ statementFailure }}</p>
180
+ <StatementView
181
+ v-else-if="state.statement.value"
182
+ :statement="state.statement.value"
183
+ :appeal="state.statement.appeal"
184
+ :withdrawing="!!state.busy.withdraw"
185
+ @appeal="ctl.fileAppeal()"
186
+ @withdraw="ctl.withdrawAppeal()"
187
+ />
188
+ <p><a :href="routes.myCommunityPath">{{ t('common.back') }}</a></p>
189
+ </template>
190
+
191
+ <!-- the panel -->
192
+ <template v-else>
193
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
194
+ <div v-else-if="state.phase === 'signIn' && !noticeMode" class="bcm-row">
195
+ <p>{{ t('panel.signIn') }}</p>
196
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
197
+ </div>
198
+ <p v-else-if="state.phase === 'failed'" class="bcm-error" role="alert">{{ memberFailure }}</p>
199
+ <template v-else-if="state.phase === 'ready'">
200
+ <!-- profile -->
201
+ <section v-if="profile && member" class="bcm-section" :aria-labelledby="`${id}-profile`">
202
+ <h3 :id="`${id}-profile`">{{ t('panel.profile') }}</h3>
203
+ <div v-if="!state.profile" class="bcm-msg">
204
+ <AuthorAvatar :author="{ kind: 'member', memberId: member.memberId, displayName: profile.displayName, avatarPreset: profile.avatarPreset }" />
205
+ <div class="bcm-msg-main">
206
+ <span class="bcm-author">{{ profile.displayName }}</span>
207
+ <span class="bcm-handle">{{ profile.handle }}</span>
208
+ <div class="bcm-row">
209
+ <button type="button" class="bcm-btn bcm-small" @click="ctl.editProfile()">{{ t('panel.profileEdit') }}</button>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ <form v-else class="bcm-form bcm-card" novalidate @submit.prevent="ctl.saveProfile()">
214
+ <div class="bcm-field">
215
+ <label :for="`${id}-name`">{{ t('onboarding.displayName') }}</label>
216
+ <input :id="`${id}-name`" v-model="state.profile.displayName" class="bcm-input" autocomplete="nickname" :aria-invalid="state.profile.fields.includes('displayName')" />
217
+ <p class="bcm-hint">{{ t('onboarding.displayNameHint') }}</p>
218
+ </div>
219
+ <div class="bcm-field">
220
+ <label :for="`${id}-handle`">{{ t('onboarding.handle') }}</label>
221
+ <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')" />
222
+ <p class="bcm-hint">{{ t('onboarding.handleHint', { min: HANDLE_MIN, max: HANDLE_MAX, days: COMMUNITY_HANDLE_CHANGE_DAYS }) }}</p>
223
+ </div>
224
+ <fieldset class="bcm-choices">
225
+ <legend>{{ t('onboarding.avatar') }}</legend>
226
+ <label class="bcm-choice">
227
+ <input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="null" />
228
+ <span>{{ t('onboarding.avatarNone') }}</span>
229
+ </label>
230
+ <label v-for="preset in AVATAR_PRESETS" :key="preset.id" class="bcm-choice">
231
+ <input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="preset.id" />
232
+ <span>{{ preset.emoji }}</span>
233
+ </label>
234
+ </fieldset>
235
+ <p v-if="state.profile.failure" class="bcm-error" role="alert">{{ text(failureText(state.profile.failure, lang)) }}</p>
236
+ <div class="bcm-row">
237
+ <span class="bcm-spacer" />
238
+ <button type="button" class="bcm-btn" :disabled="state.profile.sending" @click="ctl.cancelProfile()">{{ t('composer.cancel') }}</button>
239
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="state.profile.sending">{{ state.profile.sending ? t('composer.sending') : t('composer.save') }}</button>
240
+ </div>
241
+ </form>
242
+ </section>
243
+
244
+ <!-- notifications -->
245
+ <section class="bcm-section" :aria-labelledby="`${id}-notifications`">
246
+ <div class="bcm-row">
247
+ <h3 :id="`${id}-notifications`">{{ t('panel.notifications') }}</h3>
248
+ <span v-if="ctl.unreadCount() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unreadCount() }) }}</span>
249
+ <span class="bcm-spacer" />
250
+ <button v-if="ctl.unreadCount() > 0" type="button" class="bcm-btn bcm-small" :disabled="!!state.busy.markRead" @click="ctl.markAllRead()">{{ t('panel.markRead') }}</button>
251
+ </div>
252
+ <p v-if="state.notifications.length === 0" class="bcm-muted">{{ t('panel.notificationsEmpty') }}</p>
253
+ <ul v-else class="bcm-list">
254
+ <li v-for="n in state.notifications" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
255
+ <p v-if="notificationKey(n)">{{ t(notificationKey(n)!) }}</p>
256
+ <time class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
257
+ <a v-if="n.decisionId && statementHref(n.decisionId)" :href="statementHref(n.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
258
+ </li>
259
+ </ul>
260
+ </section>
261
+
262
+ <!-- statements -->
263
+ <section class="bcm-section" :aria-labelledby="`${id}-statements`">
264
+ <h3 :id="`${id}-statements`">{{ t('panel.statements') }}</h3>
265
+ <p v-if="state.decisions.length === 0" class="bcm-muted">{{ t('panel.statementsEmpty') }}</p>
266
+ <ul v-else class="bcm-list">
267
+ <li v-for="d in state.decisions" :key="d.decisionId">
268
+ <p v-if="restrictionKey(d.restriction)"><strong>{{ t(restrictionKey(d.restriction)!) }}</strong></p>
269
+ <p v-if="categoryKey(d.category)" class="bcm-muted">{{ t(categoryKey(d.category)!) }}</p>
270
+ <p class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, d.decidedAt) }) }}</p>
271
+ <p v-if="appealStatusKey(d.decisionId)" class="bcm-muted">{{ t(appealStatusKey(d.decisionId)!) }}</p>
272
+ <p v-else-if="d.canAppeal" class="bcm-muted">{{ t('statement.appealUntil', { date: formatDate(lang, d.appealWindowClosesAt) }) }}</p>
273
+ <a v-if="statementHref(d.decisionId)" :href="statementHref(d.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
274
+ </li>
275
+ </ul>
276
+ </section>
277
+
278
+ <!-- reports -->
279
+ <section class="bcm-section" :aria-labelledby="`${id}-reports`">
280
+ <h3 :id="`${id}-reports`">{{ t('panel.reports') }}</h3>
281
+ <p v-if="state.reports.length === 0" class="bcm-muted">{{ t('panel.reportsEmpty') }}</p>
282
+ <ul v-else class="bcm-list">
283
+ <li v-for="r in state.reports" :key="r.caseId">
284
+ <p v-if="categoryKey(r.category)"><strong>{{ t(categoryKey(r.category)!) }}</strong></p>
285
+ <p v-if="caseStatusKey(r.status)">{{ t(caseStatusKey(r.status)!) }}</p>
286
+ <time class="bcm-time" :datetime="r.reportedAt">{{ formatDate(lang, r.reportedAt) }}</time>
287
+ <p v-if="r.decidedAt" class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, r.decidedAt) }) }}</p>
288
+ </li>
289
+ </ul>
290
+ </section>
291
+
292
+ <!-- blocks -->
293
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-blocks`">
294
+ <h3 :id="`${id}-blocks`">{{ t('panel.blocks') }}</h3>
295
+ <p v-if="state.blocks.length === 0" class="bcm-muted">{{ t('panel.blocksEmpty') }}</p>
296
+ <ul v-else class="bcm-list">
297
+ <li v-for="b in state.blocks" :key="b.memberId" class="bcm-row">
298
+ <span>{{ b.author.kind === 'member' && 'displayName' in b.author && b.author.displayName ? b.author.displayName : t('author.deleted') }}</span>
299
+ <span class="bcm-spacer" />
300
+ <button type="button" class="bcm-btn bcm-small" :disabled="!!state.busy[`block:${b.memberId}`]" @click="ctl.unblock(b.memberId)">{{ t('discussion.unblock') }}</button>
301
+ </li>
302
+ </ul>
303
+ </section>
304
+
305
+ <!-- consents -->
306
+ <section v-if="member && consent !== null" class="bcm-section" :aria-labelledby="`${id}-consents`">
307
+ <h3 :id="`${id}-consents`">{{ t('panel.consents') }}</h3>
308
+ <template v-if="consent === 'accepted'">
309
+ <p>{{ t('panel.art9Given') }}</p>
310
+ <div class="bcm-row">
311
+ <button type="button" class="bcm-btn" :disabled="!!state.busy.consent" @click="confirming = 'consent'">{{ t('panel.art9Withdraw') }}</button>
312
+ </div>
313
+ </template>
314
+ <template v-else-if="consentRefused">
315
+ <p>{{ t('panel.art9Refused') }}</p>
316
+ <OnboardingSteps v-if="ctl.consent.value" :model="ctl.consent.value" :rules-href="routes.rulesPath" @cancel="ctl.closeConsent()" />
317
+ <div v-else class="bcm-row">
318
+ <button type="button" class="bcm-btn bcm-primary" @click="ctl.giveConsent()">{{ t('discussion.giveConsent') }}</button>
319
+ </div>
320
+ </template>
321
+ <p v-else>{{ t('panel.art9NotGiven') }}</p>
322
+ </section>
323
+
324
+ <!-- export -->
325
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-export`">
326
+ <h3 :id="`${id}-export`">{{ t('panel.export') }}</h3>
327
+ <p class="bcm-hint">{{ t('panel.exportHint') }}</p>
328
+ <div class="bcm-row">
329
+ <button type="button" class="bcm-btn" :disabled="state.exporting" @click="exportNow()">{{ state.exporting ? t('panel.exporting') : t('panel.export') }}</button>
330
+ </div>
331
+ </section>
332
+
333
+ <!-- the profile's deletion -->
334
+ <section v-if="member" class="bcm-section" :aria-labelledby="`${id}-erase`">
335
+ <h3 :id="`${id}-erase`">{{ t('panel.erase') }}</h3>
336
+ <template v-if="ctl.erasing()">
337
+ <p>{{ state.purgeAfter ? t('panel.eraseScheduled', { date: formatDate(lang, state.purgeAfter) }) : t('notification.erasure_scheduled') }}</p>
338
+ <div class="bcm-row">
339
+ <button type="button" class="bcm-btn bcm-primary" :disabled="!!state.busy.erase" @click="ctl.cancelErase()">{{ t('panel.eraseCancel') }}</button>
340
+ </div>
341
+ </template>
342
+ <template v-else>
343
+ <p class="bcm-hint">{{ t('panel.eraseExplain') }}</p>
344
+ <div class="bcm-row">
345
+ <button type="button" class="bcm-btn bcm-danger" :disabled="!!state.busy.erase" @click="confirming = 'erase'">{{ t('panel.erase') }}</button>
346
+ </div>
347
+ </template>
348
+ </section>
349
+ </template>
350
+ </template>
351
+
352
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
353
+
354
+ <ConfirmDialog
355
+ v-if="confirming === 'consent'"
356
+ title="panel.art9WithdrawTitle"
357
+ explain="panel.art9WithdrawExplain"
358
+ confirm="panel.art9WithdrawConfirm"
359
+ cancel="panel.art9WithdrawCancel"
360
+ :busy="!!state.busy.consent"
361
+ :fallback-focus="() => heading"
362
+ @confirm="confirmAction()"
363
+ @cancel="confirming = null"
364
+ />
365
+ <ConfirmDialog
366
+ v-if="confirming === 'erase'"
367
+ title="panel.eraseTitle"
368
+ explain="panel.eraseExplain"
369
+ confirm="panel.eraseConfirm"
370
+ cancel="panel.eraseKeep"
371
+ danger
372
+ :busy="!!state.busy.erase"
373
+ :fallback-focus="() => heading"
374
+ @confirm="confirmAction()"
375
+ @cancel="confirming = null"
376
+ />
377
+ </section>
378
+ </template>
@@ -0,0 +1,244 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-notice-form>`: the DSA notice form for anyone (m1-contracts.md §7.7, §10.4; community.md §5).
4
+ * The target comes from `target` (JSON), else `article-id`, else the discussion's report link (`#c=…&s=…`).
5
+ * Without one — the notice page opened directly, or a fragment that names nothing — the element says where a report
6
+ * starts (at the content itself) and links to the rules, instead of a form with nothing to report.
7
+ * The Turnstile widget renders into this element's light DOM (projected through the `turnstile` slot), its
8
+ * script loaded by this form only.
9
+ */
10
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useHost, watch } from 'vue'
11
+ import {
12
+ CASE_CATEGORIES,
13
+ COMMUNITY_NOTICE_DESCRIPTION_MAX,
14
+ COMMUNITY_NOTIFIER_NAME_MAX,
15
+ type CaseCategory,
16
+ type CommunityMessageKey,
17
+ type CommunityTransport,
18
+ type NoticeTarget,
19
+ } from '@blogmcp/sdk/community'
20
+ import { parseRoutes, parseTarget, routesWithDefaults, targetFromHash, targetKind } from '../core/attrs'
21
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
22
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
23
+ import { createNoticeForm, type NoticeFormController } from '../core/notice'
24
+ import { failureText } from '../core/outcomes'
25
+ import { renderTurnstile, type TurnstileWidget } from '../core/turnstile'
26
+ import { provideText, uid } from './context'
27
+
28
+ defineOptions({ inheritAttrs: false })
29
+
30
+ const props = defineProps<{
31
+ apiBase?: string
32
+ /** The path the site is served under (`/blog`): its proxy and its pages, as the defaults. */
33
+ siteBase?: string
34
+ lang?: string
35
+ turnstileSiteKey?: string
36
+ /** JSON (or an object): `{ conversationId, seq }`, `{ memberId }`, `{ handle }` or `{ articleId }`. */
37
+ target?: string | Record<string, unknown>
38
+ articleId?: string
39
+ /** JSON (or an object): `noticesPath` (the status page), `rulesPath`. */
40
+ routes?: string | Record<string, unknown>
41
+ transport?: CommunityTransport
42
+ }>()
43
+
44
+ const host = useHost()
45
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
46
+ const { t } = provideText(lang)
47
+ const id = uid('bcm-notice')
48
+ const heading = ref<HTMLElement | null>(null)
49
+ const done = ref<HTMLElement | null>(null)
50
+
51
+ function resolveTarget(): NoticeTarget | null {
52
+ const fromAttr = parseTarget(props.target)
53
+ if (fromAttr) return fromAttr
54
+ if (props.articleId) return parseTarget({ articleId: props.articleId })
55
+ return typeof location === 'undefined' ? null : targetFromHash(location.hash)
56
+ }
57
+
58
+ const siteBase = siteBaseFor(props.siteBase)
59
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
60
+ const ctl = shallowRef<NoticeFormController | null>(null)
61
+
62
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
63
+ const target = resolveTarget()
64
+ /** Nothing to report (and a usable base): the explanation instead of the form. */
65
+ const untargeted = !!client && !target
66
+ if (untargeted) {
67
+ warnOnce(
68
+ 'notice-target',
69
+ '[blogmcp-community] <blogmcp-notice-form> has no target (a target, an article-id or the report link’s #c=…&s= fragment): it shows where a report starts instead of the form',
70
+ )
71
+ }
72
+ if (client && target) {
73
+ ctl.value = createNoticeForm({
74
+ client,
75
+ lang: lang.value,
76
+ target,
77
+ captcha: !!props.turnstileSiteKey,
78
+ noticesPath: routes.value.noticesPath,
79
+ onSubmitted: (answer) => host?.dispatchEvent(new CustomEvent('bcm-notice-submitted', { detail: answer, bubbles: true, composed: true })),
80
+ })
81
+ }
82
+
83
+ const state = computed(() => ctl.value?.state ?? null)
84
+ const kind = computed(() => (state.value?.target ? targetKind(state.value.target) : null))
85
+ const targetText = computed(() => (kind.value === 'message' ? t('notice.targetMessage') : kind.value === 'article' ? t('notice.targetArticle') : t('notice.targetProfile')))
86
+ const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
87
+ const failure = computed(() => {
88
+ const f = state.value?.failure
89
+ if (!f) return ''
90
+ const r = failureText(f, lang.value)
91
+ return t(r.key, r.params)
92
+ })
93
+ const absoluteStatusLink = computed(() => {
94
+ const link = state.value?.statusLink
95
+ return link && typeof location !== 'undefined' ? `${location.origin}${link}` : (link ?? '')
96
+ })
97
+
98
+ // ── Turnstile, in the light DOM ──
99
+ let widget: TurnstileWidget | null = null
100
+ let slotted: HTMLElement | null = null
101
+ let gone = false
102
+
103
+ onMounted(async () => {
104
+ const c = ctl.value
105
+ if (!c || !props.turnstileSiteKey || !host) return
106
+ slotted = document.createElement('div')
107
+ slotted.slot = 'turnstile'
108
+ host.appendChild(slotted)
109
+ try {
110
+ const nonce = (host as HTMLElement & { nonce?: string }).nonce || host.getAttribute('nonce') || undefined
111
+ const w = await renderTurnstile(slotted, { siteKey: props.turnstileSiteKey, language: lang.value, onToken: (token) => c.setToken(token), nonce })
112
+ if (gone) {
113
+ w.remove()
114
+ return
115
+ }
116
+ widget = w
117
+ c.setCaptcha('ready')
118
+ } catch {
119
+ c.setCaptcha('unavailable')
120
+ }
121
+ })
122
+ watch(
123
+ () => state.value?.resetSignal,
124
+ (n, before) => {
125
+ if (n !== undefined && before !== undefined && n !== before) widget?.reset()
126
+ },
127
+ )
128
+ watch(
129
+ () => state.value?.receipt,
130
+ (receipt) => {
131
+ if (!receipt) return
132
+ widget?.remove()
133
+ widget = null
134
+ void nextTick(() => done.value?.focus())
135
+ },
136
+ )
137
+ onBeforeUnmount(() => {
138
+ gone = true
139
+ widget?.remove()
140
+ widget = null
141
+ slotted?.remove()
142
+ slotted = null
143
+ ctl.value?.dispose()
144
+ })
145
+ </script>
146
+
147
+ <template>
148
+ <section v-if="ctl && state" class="bcm bcm-notice" :lang="lang" :aria-labelledby="`${id}-title`">
149
+ <h2 :id="`${id}-title`" ref="heading" tabindex="-1">{{ t('notice.title') }}</h2>
150
+
151
+ <div v-if="state.receipt" ref="done" class="bcm-receipt" role="status" tabindex="-1">
152
+ <p>{{ t('notice.done') }}</p>
153
+ <p>{{ t('notice.receiptLede') }}</p>
154
+ <p v-if="state.statusLink"><a :href="state.statusLink">{{ t('notice.statusLink') }}</a></p>
155
+ <input v-if="absoluteStatusLink" class="bcm-input" readonly :value="absoluteStatusLink" :aria-label="t('notice.statusLink')" @focus="($event.target as HTMLInputElement).select()" />
156
+ <p v-if="!state.receipt.contactKept" class="bcm-note">{{ t('notice.noContactKept') }}</p>
157
+ </div>
158
+
159
+ <form v-else class="bcm-form" novalidate @submit.prevent="ctl.submit()">
160
+ <p>{{ t('notice.lede') }}</p>
161
+
162
+ <div class="bcm-field">
163
+ <span class="bcm-label">{{ t('notice.target') }}</span>
164
+ <span>{{ targetText }}</span>
165
+ </div>
166
+
167
+ <fieldset class="bcm-choices">
168
+ <legend>{{ t('notice.category') }}</legend>
169
+ <label v-for="c in CASE_CATEGORIES" :key="c" class="bcm-choice">
170
+ <input type="radio" :name="`${id}-category`" :value="c" :checked="state.category === c" @change="ctl.setCategory(c)" />
171
+ <span>{{ t(categoryKey(c)) }}</span>
172
+ </label>
173
+ </fieldset>
174
+
175
+ <div v-if="state.category === 'rules' && state.rules && state.rules.length" class="bcm-field">
176
+ <label :for="`${id}-rule`">{{ t('notice.rule') }}</label>
177
+ <select :id="`${id}-rule`" v-model="state.ruleId" class="bcm-input">
178
+ <option value="" />
179
+ <option v-for="rule in state.rules" :key="rule.id" :value="rule.id">{{ rule.title }}</option>
180
+ </select>
181
+ </div>
182
+
183
+ <div class="bcm-field">
184
+ <label :for="`${id}-description`">{{ t('notice.description') }}</label>
185
+ <textarea
186
+ :id="`${id}-description`"
187
+ v-model="state.description"
188
+ class="bcm-input bcm-textarea"
189
+ rows="5"
190
+ :aria-describedby="`${id}-description-hint`"
191
+ :aria-invalid="ctl.descriptionLength() > COMMUNITY_NOTICE_DESCRIPTION_MAX"
192
+ />
193
+ <p :id="`${id}-description-hint`" class="bcm-hint">
194
+ {{ t('notice.descriptionHint') }}
195
+ {{ t('notice.counter', { length: ctl.descriptionLength(), max: COMMUNITY_NOTICE_DESCRIPTION_MAX }) }}
196
+ </p>
197
+ </div>
198
+
199
+ <fieldset v-if="ctl.contactAllowed()" class="bcm-fieldset">
200
+ <legend>{{ t('notice.contactTitle') }}</legend>
201
+ <p class="bcm-hint">{{ t('notice.contactHint') }}</p>
202
+ <div class="bcm-field">
203
+ <label :for="`${id}-name`">{{ t('notice.name') }}</label>
204
+ <input :id="`${id}-name`" v-model="state.name" class="bcm-input" autocomplete="name" :maxlength="COMMUNITY_NOTIFIER_NAME_MAX" />
205
+ </div>
206
+ <div class="bcm-field">
207
+ <label :for="`${id}-email`">{{ t('notice.email') }}</label>
208
+ <input
209
+ :id="`${id}-email`"
210
+ v-model="state.email"
211
+ class="bcm-input"
212
+ type="email"
213
+ autocomplete="email"
214
+ :aria-invalid="!!state.failure?.fields?.includes('notifier.email')"
215
+ />
216
+ </div>
217
+ </fieldset>
218
+ <p v-else class="bcm-note">{{ t('notice.childAtRisk') }}</p>
219
+
220
+ <div v-if="state.captcha !== 'off'" class="bcm-turnstile">
221
+ <slot name="turnstile" />
222
+ <p v-if="state.captcha === 'unavailable'" class="bcm-error" role="alert">{{ t('notice.captchaUnavailable') }}</p>
223
+ </div>
224
+
225
+ <label class="bcm-check">
226
+ <input v-model="state.goodFaith" type="checkbox" />
227
+ <span>{{ t('confirm.good_faith') }}</span>
228
+ </label>
229
+
230
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
231
+
232
+ <div class="bcm-row">
233
+ <span class="bcm-spacer" />
234
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!ctl.canSubmit()">{{ state.sending ? t('notice.sending') : t('notice.submit') }}</button>
235
+ </div>
236
+ </form>
237
+ </section>
238
+
239
+ <section v-else-if="untargeted" class="bcm bcm-notice" :lang="lang" :aria-labelledby="`${id}-title`">
240
+ <h2 :id="`${id}-title`">{{ t('notice.title') }}</h2>
241
+ <p>{{ t('notice.noTarget') }}</p>
242
+ <p><a :href="routes.rulesPath">{{ t('onboarding.rulesTitle') }}</a></p>
243
+ </section>
244
+ </template>