@blogmcp/community 0.2.0 → 0.3.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 (82) hide show
  1. package/README.md +64 -12
  2. package/dist/blogmcp-community.global.js +7 -7
  3. package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
  4. package/dist/core/attrs.d.ts +17 -2
  5. package/dist/core/discussion.d.ts +92 -18
  6. package/dist/core/follow.d.ts +26 -0
  7. package/dist/core/forum-category.d.ts +85 -0
  8. package/dist/core/forum-search.d.ts +35 -0
  9. package/dist/core/forum-state.d.ts +30 -0
  10. package/dist/core/forum-words.d.ts +8 -0
  11. package/dist/core/forum.d.ts +21 -0
  12. package/dist/core/moderation.d.ts +80 -0
  13. package/dist/core/onboarding.d.ts +7 -0
  14. package/dist/core/profile.d.ts +6 -1
  15. package/dist/core/realtime.d.ts +13 -2
  16. package/dist/core/topic.d.ts +47 -0
  17. package/dist/element.d.ts +26 -1
  18. package/dist/global.d.ts +7 -6
  19. package/dist/index.d.ts +13 -4
  20. package/dist/index.js +54 -27
  21. package/dist/mount.d.ts +50 -1
  22. package/dist/react.d.ts +17 -4
  23. package/dist/react.js +32 -8
  24. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  25. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  26. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  27. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  28. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  29. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  30. package/dist/ui/ForumView.vue.d.ts +12 -0
  31. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  32. package/dist/ui/MessageComposer.vue.d.ts +6 -2
  33. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  34. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  35. package/dist/ui/TopicRow.vue.d.ts +10 -0
  36. package/dist/ui/TopicView.vue.d.ts +32 -0
  37. package/dist/ui/context.d.ts +7 -1
  38. package/dist/ui/discussion-context.d.ts +11 -3
  39. package/dist/vue.d.ts +164 -5
  40. package/dist/vue.js +134 -8
  41. package/package.json +3 -3
  42. package/src/core/attrs.ts +22 -2
  43. package/src/core/discussion.ts +309 -43
  44. package/src/core/follow.ts +139 -0
  45. package/src/core/forum-category.ts +323 -0
  46. package/src/core/forum-search.ts +153 -0
  47. package/src/core/forum-state.ts +59 -0
  48. package/src/core/forum-words.ts +38 -0
  49. package/src/core/forum.ts +83 -0
  50. package/src/core/moderation.ts +414 -0
  51. package/src/core/onboarding.ts +16 -4
  52. package/src/core/outcomes.ts +10 -0
  53. package/src/core/profile.ts +12 -1
  54. package/src/core/realtime.ts +15 -2
  55. package/src/core/topic.ts +234 -0
  56. package/src/element.ts +63 -1
  57. package/src/global.ts +27 -5
  58. package/src/index.ts +49 -2
  59. package/src/mount.ts +144 -2
  60. package/src/react.ts +45 -2
  61. package/src/ui/FollowButtonView.vue +85 -0
  62. package/src/ui/FollowToggle.vue +39 -0
  63. package/src/ui/ForumCategoryView.vue +243 -0
  64. package/src/ui/ForumModerationView.vue +309 -0
  65. package/src/ui/ForumNotices.vue +23 -0
  66. package/src/ui/ForumSearchView.vue +171 -0
  67. package/src/ui/ForumView.vue +128 -0
  68. package/src/ui/MembersOnly.vue +59 -0
  69. package/src/ui/MessageComposer.vue +51 -9
  70. package/src/ui/MessageItem.vue +12 -1
  71. package/src/ui/MessageMenu.vue +3 -2
  72. package/src/ui/ModeratorDialog.vue +102 -0
  73. package/src/ui/MyCommunityView.vue +3 -0
  74. package/src/ui/NoticeStatusView.vue +15 -2
  75. package/src/ui/NotificationCentreView.vue +37 -3
  76. package/src/ui/ProfileView.vue +15 -4
  77. package/src/ui/TopicRow.vue +70 -0
  78. package/src/ui/TopicView.vue +395 -0
  79. package/src/ui/context.ts +8 -3
  80. package/src/ui/discussion-context.ts +11 -3
  81. package/src/ui/styles.css +14 -0
  82. package/src/vue.ts +145 -3
@@ -0,0 +1,395 @@
1
+ <script lang="ts">
2
+ /** A refused age band, per element (as the discussion's: kb-api keeps nothing of it, §7.4.2). */
3
+ const refusedAges = new WeakMap<object, number>()
4
+ </script>
5
+
6
+ <script setup lang="ts">
7
+ /**
8
+ * `<blogmcp-topic conversation-id>` (m3-contracts.md §3.1–§3.5, §5.3): one forum topic — the discussion's view reused
9
+ * over the topic's thread (its messages, replies two levels deep, reactions, the composer, edits, reports, blocks, the
10
+ * onboarding with the art. 9 consent a forum write needs, live updates through the `realtime` property or the head
11
+ * poll), read with the forum's words, under the topic's header: its title (a deleted opening post reads „Smazané
12
+ * téma", a title withheld from this viewer its own words), closed, archived or pinned, its category, the follow button
13
+ * and, for a moderator of its category, the moderator's menu (close or reopen, pin, move; on a message: remove, pass to
14
+ * the team). A closed topic says so where the composer was; away mode and the forum's pause read one sentence each. The
15
+ * forum is for its members: anyone else reads one sentence and the way in; a topic the viewer may not see reads as
16
+ * not found; a forum switched off renders nothing. Dispatches `bcm-posted` as the discussion does.
17
+ */
18
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
19
+ import {
20
+ categoryPath,
21
+ communityMessageAnchor,
22
+ communityMessageSeqFromHash,
23
+ statementPath,
24
+ topicTitleOf,
25
+ type CommunityTransport,
26
+ type DiscussionMessage,
27
+ type DiscussionRestriction,
28
+ } from '@blogmcp/sdk/community'
29
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
30
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
31
+ import { formatDate } from '../core/format'
32
+ import { forumNoticesOf, pausedKey } from '../core/forum-state'
33
+ import { FORUM_WORDS } from '../core/forum-words'
34
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
35
+ import { failureText } from '../core/outcomes'
36
+ import type { TopicRealtime } from '../core/realtime'
37
+ import { createTopicController, type TopicController } from '../core/topic'
38
+ import ConfirmDialog from './ConfirmDialog.vue'
39
+ import FollowToggle from './FollowToggle.vue'
40
+ import ForumNotices from './ForumNotices.vue'
41
+ import MembersOnly from './MembersOnly.vue'
42
+ import MessageComposer from './MessageComposer.vue'
43
+ import MessageItem from './MessageItem.vue'
44
+ import MessageMenu from './MessageMenu.vue'
45
+ import ModeratorDialog from './ModeratorDialog.vue'
46
+ import OnboardingSteps from './OnboardingSteps.vue'
47
+ import ReportDialog from './ReportDialog.vue'
48
+ import { provideText, uid } from './context'
49
+ import { provideDiscussion } from './discussion-context'
50
+ import type { MenuItem } from './types'
51
+
52
+ defineOptions({ inheritAttrs: false })
53
+
54
+ const props = defineProps<{
55
+ /** The topic's conversation id. */
56
+ conversationId?: string
57
+ apiBase?: string
58
+ siteBase?: string
59
+ lang?: string
60
+ signInHref?: string
61
+ /** JSON (or an object): the host's pages — `forumPath`, `categoryPath`, `topicPath`, `rulesPath`, `myCommunityPath`, … */
62
+ routes?: string | Record<string, unknown>
63
+ /** Head poll interval in ms (default 20 000; 0 = off). */
64
+ pollMs?: number
65
+ transport?: CommunityTransport
66
+ /** Live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime`, a property. Absent: the head poll. */
67
+ realtime?: TopicRealtime
68
+ }>()
69
+
70
+ const host = useHost()
71
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
72
+ const { t, text } = provideText(lang, FORUM_WORDS)
73
+ watch(
74
+ () => props.lang,
75
+ (v) => {
76
+ lang.value = pickLang(v, documentLang())
77
+ },
78
+ )
79
+
80
+ const headingId = uid('bcm-topic')
81
+ const heading = ref<HTMLElement | null>(null)
82
+ const generation = ref(0)
83
+ const current = shallowRef<TopicController | null>(null)
84
+ const siteBase = computed(() => siteBaseFor(props.siteBase))
85
+ const client = computed(() => (siteBase.value === null ? null : communityClientFor(props.apiBase, props.transport, siteBase.value)))
86
+
87
+ function create(): TopicController | null {
88
+ const conversationId = props.conversationId?.trim()
89
+ if (!conversationId) {
90
+ warnOnce('conversation-id', '[blogmcp-community] <blogmcp-topic> needs a conversation-id')
91
+ return null
92
+ }
93
+ if (!client.value) return null
94
+ return createTopicController({
95
+ client: client.value,
96
+ conversationId,
97
+ lang: lang.value,
98
+ pollMs: props.pollMs,
99
+ realtime: typeof props.realtime === 'function' ? props.realtime : null,
100
+ refusedAge: (host && refusedAges.get(host)) ?? null,
101
+ onAgeRefused: (minAge) => {
102
+ if (host) refusedAges.set(host, minAge)
103
+ },
104
+ onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
105
+ })
106
+ }
107
+
108
+ const opened = reactive(new Set<number>())
109
+ const originals = reactive(new Set<number>())
110
+ const allReplies = reactive(new Set<number>())
111
+ const deleting = ref<number | null>(null)
112
+
113
+ const ctl = computed(() => current.value)
114
+ const thread = computed(() => current.value?.thread ?? null)
115
+ const state = computed(() => current.value?.thread.state ?? null)
116
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state.value?.me?.links, siteBase.value ?? ''))
117
+
118
+ /** A moderator's items of a message's menu (another member's live message in this topic). */
119
+ function extraMenu(m: DiscussionMessage): MenuItem[] {
120
+ if (!current.value?.canModerate(m)) return []
121
+ return [
122
+ { id: 'mod-remove', label: t('moderation.remove'), danger: true },
123
+ { id: 'mod-escalate', label: t('moderation.escalate') },
124
+ ]
125
+ }
126
+
127
+ provideDiscussion({
128
+ get ctl() {
129
+ return current.value!.thread
130
+ },
131
+ routes,
132
+ opened,
133
+ originals,
134
+ allReplies,
135
+ focusHeading: () => heading.value,
136
+ deleteMessage: (seq) => {
137
+ deleting.value = seq
138
+ },
139
+ extraMenu,
140
+ onExtraMenu: (id, m) => {
141
+ if (id === 'mod-remove') current.value?.openDialog('remove', m.seq)
142
+ else if (id === 'mod-escalate') current.value?.openDialog('escalate', m.seq)
143
+ },
144
+ })
145
+
146
+ let observer: IntersectionObserver | null = null
147
+ const section = ref<HTMLElement | null>(null)
148
+ let anchored = false
149
+
150
+ /** A link to one message (`#bcm-msg-<seq>`, topicMessageLink): scrolled into view once the first page is in. */
151
+ async function scrollToAnchor(): Promise<void> {
152
+ if (anchored || typeof location === 'undefined') return
153
+ anchored = true
154
+ const seq = communityMessageSeqFromHash(location.hash)
155
+ const anchor = seq === null ? null : communityMessageAnchor(seq)
156
+ if (seq === null || anchor === null) return
157
+ const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
158
+ if (parent) allReplies.add(parent.seq)
159
+ await nextTick()
160
+ const row = section.value?.querySelector<HTMLElement>(`#${anchor}`)
161
+ if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
162
+ }
163
+
164
+ function start(): void {
165
+ anchored = false
166
+ current.value?.dispose()
167
+ opened.clear()
168
+ originals.clear()
169
+ allReplies.clear()
170
+ deleting.value = null
171
+ current.value = create()
172
+ generation.value += 1
173
+ void current.value?.start()
174
+ }
175
+
176
+ onMounted(() => {
177
+ start()
178
+ if (host && typeof IntersectionObserver !== 'undefined') {
179
+ observer = new IntersectionObserver((entries) => {
180
+ const e = entries[entries.length - 1]
181
+ if (e) current.value?.thread.setInView(e.isIntersecting)
182
+ }, { rootMargin: '200px' })
183
+ observer.observe(host)
184
+ }
185
+ })
186
+ watch(() => [props.conversationId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
187
+ onBeforeUnmount(() => {
188
+ observer?.disconnect()
189
+ current.value?.dispose()
190
+ current.value = null
191
+ })
192
+
193
+ watch(
194
+ () => state.value?.phase,
195
+ (phase) => {
196
+ if (phase === 'ready') void scrollToAnchor()
197
+ },
198
+ )
199
+
200
+ const topic = computed(() => state.value?.thread.topic ?? null)
201
+ const title = computed(() => (state.value ? topicTitleOf(state.value.thread) : null))
202
+ const titleText = computed(() => {
203
+ const ti = title.value
204
+ if (!ti) return ''
205
+ return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
206
+ })
207
+ const categoryHref = computed(() => (topic.value?.category ? categoryPath(routes.value.categoryPath, topic.value.category.slug) : null))
208
+ const mode = computed(() => thread.value?.mode() ?? 'pending')
209
+ /** The pause's sentence where the composer was: the module's, or the forum's own (never the article discussions'). */
210
+ const pausedText = computed(() => {
211
+ const notices = forumNoticesOf(state.value?.me ?? null)
212
+ return pausedKey(notices.paused ?? 'forum')
213
+ })
214
+ const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
215
+ watch(mode, (now, before) => {
216
+ if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
217
+ if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
218
+ })
219
+ const needKey = computed(() => {
220
+ const n = thread.value?.needs()
221
+ return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
222
+ })
223
+
224
+ /** The topic's moderator menu (close or reopen, pin or unpin, move). */
225
+ const moderatorItems = computed<MenuItem[]>(() => {
226
+ const c = current.value
227
+ const tp = topic.value
228
+ if (!c || !tp || !c.isModerator()) return []
229
+ const items: MenuItem[] = []
230
+ if (tp.state === 'open') items.push({ id: 'lock', label: t('moderation.lock') })
231
+ else if (tp.state === 'locked') items.push({ id: 'unlock', label: t('moderation.unlock') })
232
+ items.push(tp.pinned ? { id: 'unpin', label: t('moderation.unpin') } : { id: 'pin', label: t('moderation.pin') })
233
+ items.push({ id: 'move', label: t('moderation.move') })
234
+ return items
235
+ })
236
+ function onModerator(id: string): void {
237
+ const c = current.value
238
+ if (!c) return
239
+ if (id === 'lock' || id === 'unlock') void c.setState(id === 'lock' ? 'locked' : 'open')
240
+ else if (id === 'pin' || id === 'unpin') void c.setPinned(id === 'pin')
241
+ else if (id === 'move') c.openDialog('move')
242
+ }
243
+
244
+ function sanctionText(r: DiscussionRestriction): string {
245
+ const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
246
+ if (r.kind === 'ban') return t('sanction.ban')
247
+ if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
248
+ return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
249
+ }
250
+
251
+ const loadFailure = computed(() => {
252
+ const f = state.value?.failure
253
+ if (!f) return ''
254
+ const r = failureText(f, lang.value)
255
+ return t(r.key, r.params)
256
+ })
257
+
258
+ // One live region for the thread's and the topic's outcomes; the same outcome twice is announced twice.
259
+ const liveText = ref('')
260
+ function announceNow(next: string): void {
261
+ liveText.value = ''
262
+ void nextTick(() => {
263
+ liveText.value = next
264
+ })
265
+ }
266
+ watch(
267
+ () => state.value?.announceId,
268
+ () => announceNow(text(state.value?.announcement)),
269
+ )
270
+ watch(
271
+ () => current.value?.extras.announceId,
272
+ () => announceNow(text(current.value?.extras.announcement)),
273
+ )
274
+
275
+ async function confirmDelete(): Promise<void> {
276
+ const seq = deleting.value
277
+ if (seq === null || !thread.value) return
278
+ await thread.value.remove(seq)
279
+ deleting.value = null
280
+ }
281
+ </script>
282
+
283
+ <template>
284
+ <section v-if="ctl && thread && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-topic" :lang="lang" :aria-labelledby="headingId">
285
+ <nav class="bcm-row">
286
+ <a v-if="categoryHref && topic?.category" :href="categoryHref">{{ topic.category.name }}</a>
287
+ <a v-else :href="routes.forumPath">{{ t('forum.back') }}</a>
288
+ </nav>
289
+
290
+ <p v-if="state.phase === 'loading'" :id="headingId" class="bcm-muted" role="status">{{ t('topic.loading') }}</p>
291
+
292
+ <div v-else-if="state.phase === 'membersOnly' && client" :id="headingId">
293
+ <MembersOnly :failure="state.failure" :client="client" :lang="lang" :sign-in-href="signInHref" :rules-href="routes.rulesPath" @joined="thread.refresh()" />
294
+ </div>
295
+
296
+ <p v-else-if="state.phase === 'notFound'" :id="headingId" class="bcm-note">{{ t('topic.notFound') }}</p>
297
+
298
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
299
+ <span :id="headingId">{{ loadFailure }}</span>
300
+ <button type="button" class="bcm-btn" @click="thread.refresh()">{{ t('common.retry') }}</button>
301
+ </div>
302
+
303
+ <template v-else>
304
+ <header class="bcm-head">
305
+ <h2 :id="headingId" ref="heading" tabindex="-1" :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</h2>
306
+ <span v-if="topic?.pinned" class="bcm-badge">{{ t('topic.pinned') }}</span>
307
+ <span v-if="topic?.state === 'locked'" class="bcm-badge">{{ t('topic.locked') }}</span>
308
+ <span v-else-if="topic?.state === 'archived'" class="bcm-badge">{{ t('topic.archived') }}</span>
309
+ <span class="bcm-count">{{ t('topic.count', { count: state.thread.count }) }}</span>
310
+ <span class="bcm-spacer" />
311
+ <FollowToggle v-if="ctl.follow && state.thread.viewer.memberId !== null" :ctl="ctl.follow" />
312
+ <MessageMenu v-if="moderatorItems.length" :items="moderatorItems" :label="t('moderation.title')" @select="onModerator" />
313
+ </header>
314
+
315
+ <ForumNotices :me="state.me" />
316
+
317
+ <div v-if="state.newMessages" class="bcm-banner" role="status">
318
+ <span>{{ t('discussion.newMessages') }}</span>
319
+ <button type="button" class="bcm-btn" :disabled="state.refreshing" @click="thread.refresh()">{{ t('discussion.refresh') }}</button>
320
+ </div>
321
+
322
+ <p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
323
+
324
+ <ol class="bcm-thread">
325
+ <MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
326
+ </ol>
327
+ <p v-if="state.thread.messages.length <= 1" class="bcm-muted">{{ t('discussion.empty') }}</p>
328
+
329
+ <div v-if="state.thread.nextCursor !== null" class="bcm-row">
330
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="thread.loadMore()">{{ t('discussion.loadMore') }}</button>
331
+ </div>
332
+
333
+ <div class="bcm-section">
334
+ <p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
335
+ <MessageComposer
336
+ v-if="mode === 'write'"
337
+ ref="composer"
338
+ kind="new"
339
+ :draft="state.composer"
340
+ :wait="thread.waitLeft(state.composer)"
341
+ :rules-href="routes.rulesPath"
342
+ :mentions-max="thread.mentionCap()"
343
+ @submit="thread.submit()"
344
+ @confirm="(code: string, checked: boolean) => thread && thread.setConfirmed(state!.composer, code, checked)"
345
+ @confirmed="thread.sendConfirmed(state.composer)"
346
+ />
347
+ <p v-else-if="mode === 'locked'" class="bcm-note">{{ t('error.topicLocked') }}</p>
348
+ <p v-else-if="mode === 'readOnly'" class="bcm-note">{{ t('error.readOnly') }}</p>
349
+ <template v-else-if="mode === 'onboarding'">
350
+ <OnboardingSteps v-if="thread.onboarding.value" :model="thread.onboarding.value" :rules-href="routes.rulesPath" @cancel="thread.cancelOnboarding()" />
351
+ <div v-else class="bcm-row">
352
+ <p>{{ t(needKey) }}</p>
353
+ <button type="button" class="bcm-btn bcm-primary" @click="thread.beginOnboarding()">{{ t('onboarding.continue') }}</button>
354
+ </div>
355
+ </template>
356
+ <p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
357
+ <div v-else-if="mode === 'sanction'" class="bcm-section">
358
+ <p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
359
+ {{ sanctionText(r) }}
360
+ <a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
361
+ </p>
362
+ </div>
363
+ <p v-else-if="mode === 'braked' && pausedText" class="bcm-note" role="status">{{ t(pausedText) }}</p>
364
+ <p v-else-if="mode === 'restricted'" class="bcm-note">
365
+ {{ t('error.restricted') }}
366
+ <a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
367
+ </p>
368
+ </div>
369
+ </template>
370
+
371
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
372
+
373
+ <ReportDialog
374
+ v-if="state.report"
375
+ :report="state.report"
376
+ :my-community-href="routes.myCommunityPath"
377
+ :rules-href="routes.rulesPath"
378
+ :fallback-focus="() => heading"
379
+ @submit="thread.submitReport()"
380
+ @close="thread.closeReport()"
381
+ />
382
+ <ConfirmDialog
383
+ v-if="deleting !== null"
384
+ title="discussion.deleteTitle"
385
+ explain="discussion.deleteExplain"
386
+ confirm="discussion.deleteConfirm"
387
+ danger
388
+ :busy="!!state.busy[`delete:${deleting}`]"
389
+ :fallback-focus="() => heading"
390
+ @confirm="confirmDelete()"
391
+ @cancel="deleting = null"
392
+ />
393
+ <ModeratorDialog v-if="ctl.extras.dialog" :ctl="ctl" :fallback-focus="() => heading" @close="ctl.closeDialog()" />
394
+ </section>
395
+ </template>
package/src/ui/context.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `communityMessage` and nothing else), ids unique to the element, and the dialog's focus handling.
4
4
  */
5
5
  import { inject, nextTick, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
6
- import { communityMessage } from '@blogmcp/sdk/community'
6
+ import { communityMessage, type CommunityMessageKey } from '@blogmcp/sdk/community'
7
7
  import type { Lang, TextRef, Translate } from '../core/i18n'
8
8
 
9
9
  export interface TextContext {
@@ -15,9 +15,14 @@ export interface TextContext {
15
15
 
16
16
  const TEXT: InjectionKey<TextContext> = Symbol('bcm-text')
17
17
 
18
- export function provideText(lang: Ref<Lang>): TextContext {
18
+ /**
19
+ * The element's texts. `words` (M3) maps a key the shared components name to another key of the catalogue — a topic's
20
+ * thread reads the discussion's components with the forum's words (`composer.label` → `forumText.composer.label`, a
21
+ * post, not a comment) — and a plural key maps by its name without the suffix.
22
+ */
23
+ export function provideText(lang: Ref<Lang>, words?: Readonly<Partial<Record<CommunityMessageKey, CommunityMessageKey>>>): TextContext {
19
24
  // Reads lang.value on every call, so a view that renders a text re-renders when the language changes.
20
- const t: Translate = (key, params) => communityMessage(lang.value, key, params)
25
+ const t: Translate = (key, params) => communityMessage(lang.value, words?.[key] ?? key, params)
21
26
  const ctx: TextContext = { lang, t, text: (ref) => (ref ? t(ref.key, ref.params) : '') }
22
27
  provide(TEXT, ctx)
23
28
  return ctx
@@ -1,10 +1,15 @@
1
- /** What the discussion's components share: the controller, the host's routes, what the reader opened. */
1
+ /**
2
+ * What the discussion's components share: the controller, the host's routes, what the reader opened — M3: under a
3
+ * topic too (`<blogmcp-topic>` provides its thread's controller, and the moderator's items of a message's menu).
4
+ */
2
5
  import { inject, provide, type ComputedRef, type InjectionKey } from 'vue'
3
- import type { DiscussionController } from '../core/discussion'
6
+ import type { DiscussionMessage } from '@blogmcp/sdk/community'
7
+ import type { ThreadController } from '../core/discussion'
4
8
  import type { routesWithDefaults } from '../core/attrs'
9
+ import type { MenuItem } from './types'
5
10
 
6
11
  export interface DiscussionContext {
7
- ctl: DiscussionController
12
+ ctl: ThreadController
8
13
  routes: ComputedRef<ReturnType<typeof routesWithDefaults>>
9
14
  /** Messages of a blocked author the reader opened anyway. */
10
15
  opened: Set<number>
@@ -15,6 +20,9 @@ export interface DiscussionContext {
15
20
  /** Where the focus goes when the element it came from is gone (a deleted message). */
16
21
  focusHeading(): HTMLElement | null
17
22
  deleteMessage(seq: number): void
23
+ /** M3: more items of a message's menu (a category moderator's), and what picking one does. */
24
+ extraMenu?(m: DiscussionMessage): MenuItem[]
25
+ onExtraMenu?(id: string, m: DiscussionMessage): void
18
26
  }
19
27
 
20
28
  const KEY: InjectionKey<DiscussionContext> = Symbol('bcm-discussion')
package/src/ui/styles.css CHANGED
@@ -257,3 +257,17 @@ span.bcm-react { cursor: default; }
257
257
  .bcm-dialog[open] { animation: bcm-in 140ms ease-out; }
258
258
  @keyframes bcm-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
259
259
  }
260
+
261
+ /* ── M3: the forum (its index, a category, a topic, search, the follow button, the moderator's queue) ── */
262
+ :host(blogmcp-follow-button) { display: inline-block; }
263
+ .bcm-follow-host { display: inline-flex; }
264
+ .bcm-follow { display: inline-flex; align-items: center; gap: 6px; }
265
+ /* Following: a thicker border and bold, not colour alone. */
266
+ .bcm-follow .bcm-btn[aria-pressed='true'] { border: 2px solid var(--bcm-accent); font-weight: 650; }
267
+ .bcm-grow { flex: 1; min-width: 0; }
268
+ .bcm-select { width: auto; max-width: 100%; }
269
+ .bcm-category { gap: 8px; }
270
+ .bcm-topics { list-style: none; display: flex; flex-direction: column; }
271
+ .bcm-topic-row { border-bottom: 1px solid var(--bcm-line); padding: 8px 0; display: flex; flex-direction: column; gap: 4px; }
272
+ .bcm-topic-row:last-child { border-bottom: 0; }
273
+ .bcm-topic-title { font-weight: 650; }
package/src/vue.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` and (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
- * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` for Vue / Nuxt: each mounts its element on the
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`, (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
+ * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` and (M3) `<KbForum>`, `<KbForumCategory>`, `<KbTopic>`,
4
+ * `<KbForumSearch>`, `<KbFollowButton>`, `<KbForumModeration>` for Vue / Nuxt: each mounts its element on the
4
5
  * client, after hydration, and removes it on unmount. Each renders one empty `<div>` as the mount point —
5
6
  * nothing else, on the server or the client — so the element sits where the component does and the page's
6
7
  * markup is otherwise the site's alone.
@@ -13,15 +14,21 @@
13
14
  import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
14
15
  import type { CommunityTransport } from '@blogmcp/sdk/community'
15
16
  import type { CommunityRoutes } from './core/attrs'
16
- import type { DiscussionRealtime } from './core/realtime'
17
+ import type { DiscussionRealtime, TopicRealtime } from './core/realtime'
17
18
  import type {
18
19
  MountBadgeOptions,
19
20
  MountDiscussionOptions,
21
+ MountFollowButtonOptions,
22
+ MountForumCategoryOptions,
23
+ MountForumModerationOptions,
24
+ MountForumOptions,
25
+ MountForumSearchOptions,
20
26
  MountMemberSettingsOptions,
21
27
  MountMyCommunityOptions,
22
28
  MountNoticeFormOptions,
23
29
  MountNotificationCentreOptions,
24
30
  MountProfileOptions,
31
+ MountTopicOptions,
25
32
  MountWelcomeOptions,
26
33
  } from './mount'
27
34
 
@@ -228,6 +235,134 @@ export const KbWelcome = defineComponent({
228
235
  },
229
236
  })
230
237
 
238
+ // ───────────────────────── M3: the forum (m3-contracts.md §5.3) ─────────────────────────
239
+
240
+ export const KbForum = defineComponent({
241
+ name: 'KbForum',
242
+ props: {
243
+ apiBase: String,
244
+ siteBase: String,
245
+ lang: String,
246
+ signInHref: String,
247
+ routes: Object as PropType<CommunityRoutes>,
248
+ nonce: String,
249
+ transport: Function as PropType<CommunityTransport>,
250
+ },
251
+ setup(props) {
252
+ return useMount<MountForumOptions>(
253
+ 'forum',
254
+ () => defined({ ...props }) as MountForumOptions,
255
+ async () => (await import('./mount')).mountForum as Mounter<MountForumOptions>,
256
+ )
257
+ },
258
+ })
259
+
260
+ export const KbForumCategory = defineComponent({
261
+ name: 'KbForumCategory',
262
+ props: {
263
+ slug: { type: String, required: true },
264
+ apiBase: String,
265
+ siteBase: String,
266
+ lang: String,
267
+ signInHref: String,
268
+ routes: Object as PropType<CommunityRoutes>,
269
+ nonce: String,
270
+ transport: Function as PropType<CommunityTransport>,
271
+ },
272
+ setup(props) {
273
+ return useMount<MountForumCategoryOptions>(
274
+ 'forum-category',
275
+ () => defined({ ...props }) as MountForumCategoryOptions,
276
+ async () => (await import('./mount')).mountForumCategory as Mounter<MountForumCategoryOptions>,
277
+ )
278
+ },
279
+ })
280
+
281
+ export const KbTopic = defineComponent({
282
+ name: 'KbTopic',
283
+ props: {
284
+ conversationId: { type: String, required: true },
285
+ apiBase: String,
286
+ siteBase: String,
287
+ lang: String,
288
+ signInHref: String,
289
+ routes: Object as PropType<CommunityRoutes>,
290
+ pollMs: Number,
291
+ nonce: String,
292
+ transport: Function as PropType<CommunityTransport>,
293
+ /** `createRealtimeSession` of `@blogmcp/sdk/community/realtime`. */
294
+ realtime: Function as PropType<TopicRealtime>,
295
+ },
296
+ setup(props) {
297
+ return useMount<MountTopicOptions>(
298
+ 'topic',
299
+ () => defined({ ...props }) as MountTopicOptions,
300
+ async () => (await import('./mount')).mountTopic as Mounter<MountTopicOptions>,
301
+ )
302
+ },
303
+ })
304
+
305
+ export const KbForumSearch = defineComponent({
306
+ name: 'KbForumSearch',
307
+ props: {
308
+ apiBase: String,
309
+ siteBase: String,
310
+ lang: String,
311
+ signInHref: String,
312
+ routes: Object as PropType<CommunityRoutes>,
313
+ nonce: String,
314
+ transport: Function as PropType<CommunityTransport>,
315
+ },
316
+ setup(props) {
317
+ return useMount<MountForumSearchOptions>(
318
+ 'forum-search',
319
+ () => defined({ ...props }) as MountForumSearchOptions,
320
+ async () => (await import('./mount')).mountForumSearch as Mounter<MountForumSearchOptions>,
321
+ )
322
+ },
323
+ })
324
+
325
+ export const KbFollowButton = defineComponent({
326
+ name: 'KbFollowButton',
327
+ props: {
328
+ targetKind: { type: String as PropType<'conversation' | 'category'>, required: true },
329
+ targetId: { type: String, required: true },
330
+ following: { type: Boolean, default: undefined },
331
+ apiBase: String,
332
+ siteBase: String,
333
+ lang: String,
334
+ nonce: String,
335
+ transport: Function as PropType<CommunityTransport>,
336
+ },
337
+ setup(props) {
338
+ return useMount<MountFollowButtonOptions>(
339
+ 'follow-button',
340
+ () => defined({ ...props }) as MountFollowButtonOptions,
341
+ async () => (await import('./mount')).mountFollowButton as Mounter<MountFollowButtonOptions>,
342
+ )
343
+ },
344
+ })
345
+
346
+ export const KbForumModeration = defineComponent({
347
+ name: 'KbForumModeration',
348
+ props: {
349
+ categoryId: String,
350
+ apiBase: String,
351
+ siteBase: String,
352
+ lang: String,
353
+ routes: Object as PropType<CommunityRoutes>,
354
+ nonce: String,
355
+ transport: Function as PropType<CommunityTransport>,
356
+ },
357
+ setup(props) {
358
+ return useMount<MountForumModerationOptions>(
359
+ 'forum-moderation',
360
+ () => defined({ ...props }) as MountForumModerationOptions,
361
+ async () => (await import('./mount')).mountForumModeration as Mounter<MountForumModerationOptions>,
362
+ )
363
+ },
364
+ })
365
+
231
366
  export type {
232
367
  MountDiscussionOptions,
233
368
  MountNoticeFormOptions,
@@ -237,6 +372,13 @@ export type {
237
372
  MountMemberSettingsOptions,
238
373
  MountProfileOptions,
239
374
  MountWelcomeOptions,
375
+ MountForumOptions,
376
+ MountForumCategoryOptions,
377
+ MountTopicOptions,
378
+ MountForumSearchOptions,
379
+ MountFollowButtonOptions,
380
+ MountForumModerationOptions,
240
381
  CommunityRoutes,
241
382
  DiscussionRealtime,
383
+ TopicRealtime,
242
384
  }