@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,309 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-forum-moderation>` (m3-contracts.md §3.4, §4.2, §4.5, §5.3; D-M3-6): a category moderator's queue — the
4
+ * cases of category `rules` in their categories (a member's report, the filters' hold, the team's) and the messages
5
+ * waiting in pre-moderation. A case opens to its package as written (text only: no reporter, no note, no contact) and
6
+ * the decision through the engine — restore, hide or remove, with the facts and the rule it rests on —, its statement
7
+ * of reasons previewed first and decided after; an illegal line's matter passes to the team. A pre-moderated message
8
+ * is published (no statement: nothing was restricted) or refused (a rule and the facts: the statement comes back) —
9
+ * M3 FX (R3): of the text shown here (kb-api answers it with the queue, rendered as text): an edit by its author
10
+ * meanwhile shows the new text and asks again (`moderation.textChanged`). M3 FX2 (S12): a package item of an opening
11
+ * post whose opening post was deleted reads „Smazané téma" (kb-api's `openingState`, topicTitleOf).
12
+ * Renders nothing for a member who moderates nothing now (and for anyone the forum is not open to).
13
+ */
14
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
15
+ import {
16
+ ESCALATION_CATEGORIES,
17
+ MODERATION_RESTRICTIONS,
18
+ hasCommunityMessage,
19
+ topicMessageLink,
20
+ topicTitleOf,
21
+ type CaseCategory,
22
+ type CommunityMessageKey,
23
+ type CommunityTransport,
24
+ type ModerationCaseItem,
25
+ type ModerationItem,
26
+ type ModerationRestriction,
27
+ } from '@blogmcp/sdk/community'
28
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
29
+ import { communityClientFor, siteBaseFor } from '../core/client'
30
+ import { formatDate } from '../core/format'
31
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
32
+ import { createModeration, type ModerationController } from '../core/moderation'
33
+ import { failureText } from '../core/outcomes'
34
+ import { provideText, uid } from './context'
35
+
36
+ defineOptions({ inheritAttrs: false })
37
+
38
+ const props = defineProps<{
39
+ /** Narrow the queue to one of the moderator's categories (its id). */
40
+ categoryId?: string
41
+ apiBase?: string
42
+ siteBase?: string
43
+ lang?: string
44
+ routes?: string | Record<string, unknown>
45
+ transport?: CommunityTransport
46
+ }>()
47
+
48
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
49
+ const { t, text } = provideText(lang)
50
+ const id = uid('bcm-moderation')
51
+ watch(
52
+ () => props.lang,
53
+ (v) => {
54
+ lang.value = pickLang(v, documentLang())
55
+ },
56
+ )
57
+
58
+ const siteBase = siteBaseFor(props.siteBase)
59
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
60
+ const ctl: ModerationController | null = client ? createModeration({ client, lang: lang.value, categoryId: props.categoryId ?? null }) : null
61
+ const state = ctl?.state ?? null
62
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
63
+
64
+ /** An opening post's package item without its title: „Smazané téma" for a deleted opening post, the withheld wording otherwise (topicTitleOf). */
65
+ function missingTitle(it: ModerationCaseItem): string {
66
+ const ti = topicTitleOf({ title: it.title ?? null, openingState: it.openingState })
67
+ return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
68
+ }
69
+
70
+ onMounted(() => void ctl?.load())
71
+ onBeforeUnmount(() => ctl?.dispose())
72
+
73
+ const RESTRICTIONS: readonly ModerationRestriction[] = MODERATION_RESTRICTIONS
74
+ const restrictionKey = (r: ModerationRestriction): CommunityMessageKey => `restriction.${r}`
75
+ const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
76
+ const caseCategoryText = (c: string | null): string => {
77
+ const key = c === null ? null : `category.${c}`
78
+ return key !== null && hasCommunityMessage(key) ? t(key) : ''
79
+ }
80
+ const sourceText = (item: ModerationItem): string => {
81
+ const key = item.source === null ? null : `moderation.source.${item.source}`
82
+ return key !== null && hasCommunityMessage(key) ? t(key) : ''
83
+ }
84
+ const categoryName = (categoryId: string): string => state?.categories.find((c) => c.id.toLowerCase() === categoryId.toLowerCase())?.name ?? ''
85
+ const ruleTitle = (ruleId: string | null): string => (ruleId === null ? '' : (state?.rules?.find((r) => r.id === ruleId)?.title ?? ruleId))
86
+ const itemHref = (item: ModerationItem): string | null => topicMessageLink(routes.value.topicPath, item.conversationId, item.seq)
87
+ const failureOf = (f: { code: Parameters<typeof failureText>[0]['code'] } | null | undefined): string => {
88
+ if (!f) return ''
89
+ const r = failureText(f, lang.value)
90
+ return t(r.key, r.params)
91
+ }
92
+
93
+ const open = computed(() => state?.open ?? null)
94
+ const review = computed(() => state?.review ?? null)
95
+ /** The rule select shows when the decision needs one chosen: kb-api asked, or the case names none for a restriction. */
96
+ const ruleNeeded = computed(() => {
97
+ const c = open.value
98
+ return !!c && c.restriction !== '' && c.restriction !== 'no_action' && (c.needsRule || c.kase?.ruleId === null)
99
+ })
100
+ // A changed decision is a new one: its statement is previewed again before anything is decided.
101
+ watch(
102
+ () => [open.value?.restriction, open.value?.facts, open.value?.ground] as const,
103
+ () => {
104
+ const c = open.value
105
+ if (c && !c.decided) c.preview = null
106
+ },
107
+ )
108
+
109
+ const liveText = ref('')
110
+ watch(
111
+ () => state?.announceId,
112
+ () => {
113
+ liveText.value = ''
114
+ const next = text(state?.announcement)
115
+ void nextTick(() => {
116
+ liveText.value = next
117
+ })
118
+ },
119
+ )
120
+ </script>
121
+
122
+ <template>
123
+ <section v-if="ctl && state && state.phase !== 'none' && state.phase !== 'hidden' && state.phase !== 'membersOnly'" class="bcm bcm-forum-moderation" :lang="lang" :aria-labelledby="`${id}-title`">
124
+ <header class="bcm-head">
125
+ <h2 :id="`${id}-title`">{{ t('moderation.title') }}</h2>
126
+ </header>
127
+
128
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('moderation.loading') }}</p>
129
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
130
+ <span>{{ failureOf(state.failure) }}</span>
131
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
132
+ </div>
133
+
134
+ <template v-else>
135
+ <div v-if="state.categories.length > 1" class="bcm-row">
136
+ <label :for="`${id}-category`">{{ t('search.category') }}</label>
137
+ <select :id="`${id}-category`" class="bcm-input bcm-select" :value="state.categoryId" @change="ctl.setCategory(($event.target as HTMLSelectElement).value)">
138
+ <option value="">{{ t('forum.allCategories') }}</option>
139
+ <option v-for="c in state.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
140
+ </select>
141
+ </div>
142
+
143
+ <p v-if="state.items.length === 0" class="bcm-muted">{{ t('moderation.empty') }}</p>
144
+ <ul v-else class="bcm-list">
145
+ <li v-for="item in state.items" :key="`${item.kind}:${item.caseId ?? ''}:${item.conversationId}:${item.seq}`">
146
+ <p class="bcm-row">
147
+ <span class="bcm-badge">{{ item.kind === 'premod' ? t('moderation.premod') : sourceText(item) }}</span>
148
+ <strong v-if="item.kind === 'case' && caseCategoryText(item.caseCategory)">{{ caseCategoryText(item.caseCategory) }}</strong>
149
+ <span v-if="categoryName(item.categoryId)" class="bcm-muted">{{ categoryName(item.categoryId) }}</span>
150
+ </p>
151
+ <p v-if="item.caseStatus === 'decided'" class="bcm-hint">{{ t('moderation.heldBySystem') }}</p>
152
+ <!-- R3: a pre-moderated message's text (and an opening post's title), as written — rendered as text. -->
153
+ <template v-if="item.kind === 'premod'">
154
+ <p v-if="item.title" class="bcm-topic-title">{{ item.title }}</p>
155
+ <p v-if="item.body !== null" class="bcm-text">{{ item.body }}</p>
156
+ </template>
157
+ <p v-if="item.ruleId" class="bcm-muted">{{ t('statement.contractualGround') }}: {{ ruleTitle(item.ruleId) }}</p>
158
+ <p class="bcm-row bcm-muted">
159
+ <time :datetime="item.createdAt">{{ formatDate(lang, item.createdAt) }}</time>
160
+ <span v-if="item.deadlineAt">{{ t('moderation.deadline', { date: formatDate(lang, item.deadlineAt) }) }}</span>
161
+ </p>
162
+ <div class="bcm-row">
163
+ <a v-if="itemHref(item)" :href="itemHref(item) ?? undefined">{{ t('moderation.openTopic') }}</a>
164
+ <span class="bcm-spacer" />
165
+ <template v-if="item.kind === 'case'">
166
+ <button type="button" class="bcm-btn bcm-small" @click="ctl.openCase(item)">{{ t('moderation.open') }}</button>
167
+ </template>
168
+ <template v-else>
169
+ <button type="button" class="bcm-btn bcm-small" @click="ctl.openReview(item, 'approve')">{{ t('moderation.approve') }}</button>
170
+ <button type="button" class="bcm-btn bcm-small bcm-danger" @click="ctl.openReview(item, 'reject')">{{ t('moderation.reject') }}</button>
171
+ </template>
172
+ </div>
173
+ </li>
174
+ </ul>
175
+ <div v-if="state.nextCursor !== null" class="bcm-row">
176
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
177
+ </div>
178
+
179
+ <!-- A case: its package as written, then the decision through the engine with its statement previewed. -->
180
+ <section v-if="open" class="bcm-card" :aria-labelledby="`${id}-case`">
181
+ <h3 :id="`${id}-case`">{{ t('moderation.package') }}</h3>
182
+ <p v-if="open.loading" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
183
+ <ol v-else-if="open.kase" class="bcm-list">
184
+ <li v-for="it in open.kase.items" :key="it.itemNo">
185
+ <p v-if="it.title" class="bcm-topic-title">{{ it.title }}</p>
186
+ <!-- An opening post's item whose title kb-api withholds, or cleared with its deleted opening post (M3 FX2, S12: openingState): the title's own words. -->
187
+ <p v-else-if="it.title === null" class="bcm-topic-title bcm-placeholder">{{ missingTitle(it) }}</p>
188
+ <p v-if="it.body !== null" class="bcm-text">{{ it.body }}</p>
189
+ <!-- A message of the package beside the subject that others cannot see now: kb-api leaves its text out. -->
190
+ <p v-else class="bcm-placeholder">{{ t('moderation.itemWithheld') }}</p>
191
+ <p v-if="it.authorHandle || it.createdAt" class="bcm-row bcm-muted">
192
+ <span v-if="it.authorHandle" class="bcm-handle">{{ it.authorHandle }}</span>
193
+ <time v-if="it.createdAt" :datetime="it.createdAt">{{ formatDate(lang, it.createdAt) }}</time>
194
+ </p>
195
+ </li>
196
+ </ol>
197
+
198
+ <form v-if="open.kase && !open.decided" class="bcm-form" novalidate @submit.prevent="open.preview === null ? ctl.preview() : ctl.decide()">
199
+ <fieldset class="bcm-fieldset">
200
+ <legend>{{ t('moderation.restriction') }}</legend>
201
+ <label v-for="r in RESTRICTIONS" :key="r" class="bcm-check">
202
+ <input v-model="open.restriction" type="radio" :value="r" />
203
+ <span>{{ t(restrictionKey(r)) }}</span>
204
+ </label>
205
+ </fieldset>
206
+ <div class="bcm-field">
207
+ <label :for="`${id}-facts`">{{ t('statement.facts') }}</label>
208
+ <textarea :id="`${id}-facts`" v-model="open.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-facts-hint`" />
209
+ <p :id="`${id}-facts-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
210
+ </div>
211
+ <div v-if="ruleNeeded || open.ground !== ''" class="bcm-field">
212
+ <label :for="`${id}-ground`">{{ t('statement.contractualGround') }}</label>
213
+ <select :id="`${id}-ground`" v-model="open.ground" class="bcm-input bcm-select" :aria-invalid="open.needsRule && open.ground === ''">
214
+ <option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
215
+ <option v-for="r in state.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
216
+ </select>
217
+ </div>
218
+ <p v-if="open.failure" class="bcm-error" role="alert">{{ open.needsRule ? t('moderation.ruleRequired') : failureOf(open.failure) }}</p>
219
+ <template v-if="open.preview !== null">
220
+ <h4>{{ t('moderation.previewTitle') }}</h4>
221
+ <pre class="bcm-facts">{{ open.preview }}</pre>
222
+ </template>
223
+ <div class="bcm-row">
224
+ <button type="button" class="bcm-btn" @click="ctl.closeCase()">{{ t('common.close') }}</button>
225
+ <span class="bcm-spacer" />
226
+ <button v-if="open.preview === null" type="submit" class="bcm-btn" :disabled="!ctl.canPreview()">{{ t('moderation.preview') }}</button>
227
+ <button v-else type="submit" class="bcm-btn bcm-primary" :disabled="open.deciding">{{ t('moderation.decide') }}</button>
228
+ </div>
229
+ </form>
230
+ <template v-else-if="open.decided">
231
+ <p>{{ t('moderation.decided') }}</p>
232
+ <h4>{{ t('statement.title') }}</h4>
233
+ <pre class="bcm-facts">{{ open.preview }}</pre>
234
+ <div class="bcm-row">
235
+ <button type="button" class="bcm-btn" @click="ctl.closeCase()">{{ t('common.close') }}</button>
236
+ </div>
237
+ </template>
238
+
239
+ <!-- An illegal line's matter: to the team (the moderator sees nothing of it afterwards). -->
240
+ <div v-if="open.kase && !open.decided" class="bcm-section">
241
+ <button v-if="!open.escalation" type="button" class="bcm-link" @click="ctl.startEscalation()">{{ t('moderation.escalate') }}</button>
242
+ <form v-else-if="!open.escalation.done" class="bcm-form" novalidate @submit.prevent="ctl.escalate()">
243
+ <p>{{ t('moderation.escalateLede') }}</p>
244
+ <div class="bcm-field">
245
+ <label :for="`${id}-escalate`">{{ t('moderation.escalateCategory') }}</label>
246
+ <select :id="`${id}-escalate`" v-model="open.escalation.category" class="bcm-input bcm-select">
247
+ <option value="" disabled>{{ t('moderation.escalateCategory') }}</option>
248
+ <option v-for="c in ESCALATION_CATEGORIES" :key="c" :value="c">{{ t(categoryKey(c)) }}</option>
249
+ </select>
250
+ </div>
251
+ <p v-if="open.escalation.failure" class="bcm-error" role="alert">{{ failureOf(open.escalation.failure) }}</p>
252
+ <div class="bcm-row">
253
+ <button type="submit" class="bcm-btn" :disabled="open.escalation.category === '' || open.escalation.sending">{{ t('moderation.escalate') }}</button>
254
+ </div>
255
+ </form>
256
+ <p v-else>{{ t('moderation.escalated') }}</p>
257
+ </div>
258
+ </section>
259
+
260
+ <!-- A pre-moderated message: published without a statement, or refused with one. -->
261
+ <section v-if="review" class="bcm-card" :aria-labelledby="`${id}-review`">
262
+ <h3 :id="`${id}-review`">{{ review.outcome === 'approve' ? t('moderation.approve') : t('moderation.reject') }}</h3>
263
+ <template v-if="review.done">
264
+ <p>{{ review.done === 'approved' ? t('moderation.approved') : t('moderation.rejected') }}</p>
265
+ <pre v-if="review.statement" class="bcm-facts">{{ review.statement }}</pre>
266
+ <div class="bcm-row">
267
+ <button type="button" class="bcm-btn" @click="ctl.closeReview()">{{ t('common.close') }}</button>
268
+ </div>
269
+ </template>
270
+ <form v-else class="bcm-form" novalidate @submit.prevent="ctl.review()">
271
+ <!-- R3: the text the decision is about; an edit by its author meanwhile brings the new one. -->
272
+ <p v-if="review.changed" class="bcm-banner" role="alert">{{ t('moderation.textChanged') }}</p>
273
+ <p v-if="review.item.title" class="bcm-topic-title">{{ review.item.title }}</p>
274
+ <p v-if="review.item.body !== null" class="bcm-text">{{ review.item.body }}</p>
275
+ <template v-if="review.outcome === 'reject'">
276
+ <div class="bcm-field">
277
+ <label :for="`${id}-review-rule`">{{ t('statement.contractualGround') }}</label>
278
+ <select :id="`${id}-review-rule`" v-model="review.ruleId" class="bcm-input bcm-select">
279
+ <option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
280
+ <option v-for="r in state.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
281
+ </select>
282
+ </div>
283
+ <div class="bcm-field">
284
+ <label :for="`${id}-review-facts`">{{ t('statement.facts') }}</label>
285
+ <textarea :id="`${id}-review-facts`" v-model="review.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-review-hint`" />
286
+ <p :id="`${id}-review-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
287
+ </div>
288
+ </template>
289
+ <p v-if="review.failure" class="bcm-error" role="alert">{{ failureOf(review.failure) }}</p>
290
+ <div class="bcm-row">
291
+ <button type="button" class="bcm-btn" :disabled="review.sending" @click="ctl.closeReview()">{{ t('common.cancel') }}</button>
292
+ <span class="bcm-spacer" />
293
+ <button
294
+ type="submit"
295
+ class="bcm-btn bcm-primary"
296
+ :class="{ 'bcm-danger': review.outcome === 'reject' }"
297
+ :disabled="review.sending || (review.outcome === 'reject' && (review.ruleId === '' || review.facts.trim() === ''))"
298
+ >
299
+ {{ review.outcome === 'approve' ? t('moderation.approve') : t('moderation.reject') }}
300
+ </button>
301
+ </div>
302
+ </form>
303
+ </section>
304
+ </template>
305
+
306
+ <!-- The one live region: a decision, an escalation or a review is announced here once (their lines carry no role). -->
307
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
308
+ </section>
309
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The forum's notices from `me/get` (m3-contracts.md §4.8, §4.9; D-M3-9): the team's away mode in one sentence
4
+ * („Tým je teď nepřítomen; nová témata počkají na kontrolu."), and the pause of the forum's writes — the module's brake
5
+ * or the forum's own (the article discussions go on under the latter) — or the module's `read_only`. Reading goes on.
6
+ */
7
+ import { computed } from 'vue'
8
+ import type { MyState } from '@blogmcp/sdk/community'
9
+ import { forumNoticesOf, pausedKey } from '../core/forum-state'
10
+ import { useText } from './context'
11
+
12
+ const props = defineProps<{ me: MyState | null; now?: number }>()
13
+ const { t } = useText()
14
+
15
+ const notices = computed(() => forumNoticesOf(props.me, props.now))
16
+ const paused = computed(() => pausedKey(notices.value.paused))
17
+ </script>
18
+
19
+ <template>
20
+ <p v-if="paused" class="bcm-note" role="status">{{ t(paused) }}</p>
21
+ <p v-else-if="notices.readOnly" class="bcm-note" role="status">{{ t('error.readOnly') }}</p>
22
+ <p v-if="notices.away" class="bcm-note" role="status">{{ t('state.away') }}</p>
23
+ </template>
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-forum-search>` (m3-contracts.md §3.2, §4.4, §5.3; D-M3-5): the forum's search for its members — a query of
4
+ * 2 to 200 characters (said before it goes: kb-api would refuse it and spend the quota), the space (the forum, the
5
+ * article discussions or both) and a category to narrow by; 20 hits a page, five pages at most. A hit reads as text:
6
+ * a topic's title or a message's snippet (at most 200 characters, no markup), linked to the topic (a message by its
7
+ * anchor) — an article discussion's hit to its message on the article (the host's `articlePath` with kb-api's
8
+ * `articleSlug`, the wave 2 integration; a host without `articlePath` keeps it text); a topic's title reads as on a
9
+ * category's row (topicTitleOf — M3 FX2, S12: „Smazané téma" for a hit under a deleted opening post, kb-api's
10
+ * `openingState`; the withheld wording for a title kept from the viewer). The forum's search box brings the query in
11
+ * the page's fragment (`#q=…`), which never reaches a server; a new `#q=` on the same page searches again.
12
+ */
13
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
14
+ import {
15
+ COMMUNITY_SEARCH_PAGES_MAX,
16
+ COMMUNITY_SEARCH_QUERY_MAX,
17
+ COMMUNITY_SEARCH_QUERY_MIN,
18
+ communityMessageLink,
19
+ topicMessageLink,
20
+ topicPath,
21
+ topicTitleOf,
22
+ type CommunityTransport,
23
+ type ForumSearchResult,
24
+ } from '@blogmcp/sdk/community'
25
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
26
+ import { communityClientFor, siteBaseFor } from '../core/client'
27
+ import { createSearch, queryFromHash, type SearchController } from '../core/forum-search'
28
+ import { formatDate } from '../core/format'
29
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
30
+ import { failureText } from '../core/outcomes'
31
+ import MembersOnly from './MembersOnly.vue'
32
+ import { provideText, uid } from './context'
33
+
34
+ defineOptions({ inheritAttrs: false })
35
+
36
+ const props = defineProps<{
37
+ apiBase?: string
38
+ siteBase?: string
39
+ lang?: string
40
+ routes?: string | Record<string, unknown>
41
+ signInHref?: string
42
+ transport?: CommunityTransport
43
+ }>()
44
+
45
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
46
+ const { t } = provideText(lang)
47
+ const id = uid('bcm-search')
48
+ watch(
49
+ () => props.lang,
50
+ (v) => {
51
+ lang.value = pickLang(v, documentLang())
52
+ },
53
+ )
54
+
55
+ const siteBase = siteBaseFor(props.siteBase)
56
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
57
+ const ctl: SearchController | null = client ? createSearch({ client, query: queryFromHash(typeof location === 'undefined' ? null : location.hash) }) : null
58
+ const state = ctl?.state ?? null
59
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
60
+
61
+ // A new `#q=` on the same page (the forum's search box there, the history's back and forward) is a new search.
62
+ function onHashChange(): void {
63
+ const q = queryFromHash(location.hash)
64
+ if (!ctl || !state || q === null || q === state.query) return
65
+ state.query = q
66
+ void ctl.search()
67
+ }
68
+ onMounted(() => {
69
+ void ctl?.load()
70
+ if (typeof window !== 'undefined') window.addEventListener('hashchange', onHashChange)
71
+ })
72
+ onBeforeUnmount(() => {
73
+ if (typeof window !== 'undefined') window.removeEventListener('hashchange', onHashChange)
74
+ ctl?.dispose()
75
+ })
76
+
77
+ /** A hit's link: the topic (a message by its anchor), or an article discussion's message on its article. */
78
+ function hrefOf(r: ForumSearchResult): string | null {
79
+ if (r.space === 'article') return communityMessageLink(routes.value.articlePath, r.articleSlug, r.seq)
80
+ if (r.space !== 'forum') return null
81
+ return r.seq !== null && r.kind === 'message' ? topicMessageLink(routes.value.topicPath, r.conversationId, r.seq) : topicPath(routes.value.topicPath, r.conversationId)
82
+ }
83
+ /** A hit's title: a topic's as topicTitleOf reads it (shown, deleted, withheld); an article discussion's, its article's. */
84
+ function titleOf(r: ForumSearchResult): string {
85
+ if (r.space === 'forum') {
86
+ const ti = topicTitleOf(r)
87
+ return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
88
+ }
89
+ return r.title !== null && r.title.trim() !== '' ? r.title : t('search.inArticle')
90
+ }
91
+ const failure = 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 lastPage = computed(() => !!state && state.phase === 'ready' && state.page >= COMMUNITY_SEARCH_PAGES_MAX && state.results.length > 0)
98
+ </script>
99
+
100
+ <template>
101
+ <section v-if="ctl && state && state.phase !== 'hidden'" class="bcm bcm-forum-search" :lang="lang" :aria-labelledby="`${id}-title`">
102
+ <header class="bcm-head">
103
+ <h2 :id="`${id}-title`">{{ t('search.title') }}</h2>
104
+ </header>
105
+
106
+ <MembersOnly
107
+ v-if="state.phase === 'membersOnly' && client"
108
+ :failure="state.failure"
109
+ :client="client"
110
+ :lang="lang"
111
+ :sign-in-href="signInHref"
112
+ :rules-href="routes.rulesPath"
113
+ @joined="ctl.load()"
114
+ />
115
+
116
+ <!-- An element, not a fragment: it holds a form (see ForumView). -->
117
+ <div v-else class="bcm-search-body">
118
+ <form class="bcm-form" role="search" novalidate @submit.prevent="ctl.search()">
119
+ <div class="bcm-field">
120
+ <label :for="`${id}-q`">{{ t('search.label') }}</label>
121
+ <input :id="`${id}-q`" v-model="state.query" class="bcm-input" type="search" :aria-invalid="state.invalid !== null" :aria-describedby="`${id}-q-error`" />
122
+ <p v-if="state.invalid" :id="`${id}-q-error`" class="bcm-error" role="alert">
123
+ {{ state.invalid === 'short' ? t('search.tooShort', { min: COMMUNITY_SEARCH_QUERY_MIN }) : t('search.tooLong', { max: COMMUNITY_SEARCH_QUERY_MAX }) }}
124
+ </p>
125
+ </div>
126
+ <div class="bcm-row">
127
+ <label :for="`${id}-space`">{{ t('search.space') }}</label>
128
+ <select :id="`${id}-space`" v-model="state.space" class="bcm-input bcm-select">
129
+ <option value="">{{ t('search.spaceAll') }}</option>
130
+ <option value="forum">{{ t('search.spaceForum') }}</option>
131
+ <option value="article">{{ t('search.spaceArticle') }}</option>
132
+ </select>
133
+ <template v-if="state.categories.length > 0 && state.space !== 'article'">
134
+ <label :for="`${id}-category`">{{ t('search.category') }}</label>
135
+ <select :id="`${id}-category`" v-model="state.categoryId" class="bcm-input bcm-select">
136
+ <option value="">{{ t('forum.allCategories') }}</option>
137
+ <option v-for="c in state.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
138
+ </select>
139
+ </template>
140
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="state.phase === 'searching'">{{ t('search.submit') }}</button>
141
+ </div>
142
+ </form>
143
+
144
+ <p v-if="state.phase === 'searching'" class="bcm-muted" role="status">{{ t('search.searching') }}</p>
145
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
146
+ <span>{{ failure }}</span>
147
+ </div>
148
+ <template v-else-if="state.phase === 'ready'">
149
+ <p v-if="state.results.length === 0" class="bcm-muted" role="status">{{ t('search.empty') }}</p>
150
+ <ol v-else class="bcm-list">
151
+ <li v-for="(r, i) in state.results" :key="`${r.conversationId}:${r.seq ?? 0}:${i}`">
152
+ <p class="bcm-row bcm-muted">
153
+ <span class="bcm-badge">{{ r.kind === 'topic' ? t('search.topic') : t('search.message') }}</span>
154
+ <time :datetime="r.createdAt">{{ formatDate(lang, r.createdAt) }}</time>
155
+ </p>
156
+ <p class="bcm-topic-title">
157
+ <a v-if="hrefOf(r)" :href="hrefOf(r) ?? undefined" :class="{ 'bcm-placeholder': r.title === null }">{{ titleOf(r) }}</a>
158
+ <span v-else :class="{ 'bcm-placeholder': r.title === null }">{{ titleOf(r) }}</span>
159
+ </p>
160
+ <p v-if="r.snippet" class="bcm-text">{{ r.snippet }}</p>
161
+ </li>
162
+ </ol>
163
+ <p v-if="state.failure" class="bcm-error" role="alert">{{ failure }}</p>
164
+ <div v-if="state.hasMore" class="bcm-row">
165
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.more()">{{ t('search.more') }}</button>
166
+ </div>
167
+ <p v-else-if="lastPage" class="bcm-hint">{{ t('search.lastPage') }}</p>
168
+ </template>
169
+ </div>
170
+ </section>
171
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-forum>` (m3-contracts.md §5.3): the forum's index — the listed categories in the team's order, each with its
4
+ * description, how many topics (in „Články": article discussions) another member sees there, its last activity and its
5
+ * latest topics, and the search box (it opens the search page with the query in the fragment: `#q=…` never reaches a
6
+ * server). The forum is for its members: anyone else reads one sentence and the way in; a forum switched off renders
7
+ * nothing. Away mode and the forum's pause read one sentence each.
8
+ */
9
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
10
+ import { COMMUNITY_SEARCH_QUERY_MIN, categoryPath, type CommunityTransport, type ForumCategory } from '@blogmcp/sdk/community'
11
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
12
+ import { communityClientFor, siteBaseFor } from '../core/client'
13
+ import { createForum, type ForumController } from '../core/forum'
14
+ import { searchLink } from '../core/forum-search'
15
+ import { formatDate } from '../core/format'
16
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
17
+ import { failureText } from '../core/outcomes'
18
+ import ForumNotices from './ForumNotices.vue'
19
+ import MembersOnly from './MembersOnly.vue'
20
+ import TopicRow from './TopicRow.vue'
21
+ import { provideText, uid } from './context'
22
+
23
+ defineOptions({ inheritAttrs: false })
24
+
25
+ const props = defineProps<{
26
+ apiBase?: string
27
+ siteBase?: string
28
+ lang?: string
29
+ /** JSON (or an object): the host's pages — `forumPath`, `categoryPath`, `topicPath`, `searchPath`, `rulesPath`, … */
30
+ routes?: string | Record<string, unknown>
31
+ signInHref?: string
32
+ transport?: CommunityTransport
33
+ }>()
34
+
35
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
36
+ const { t } = provideText(lang)
37
+ const id = uid('bcm-forum')
38
+
39
+ const siteBase = siteBaseFor(props.siteBase)
40
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
41
+ const ctl: ForumController | null = client ? createForum({ client }) : null
42
+ const state = ctl?.state ?? null
43
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
44
+
45
+ onMounted(() => void ctl?.load())
46
+ onBeforeUnmount(() => ctl?.dispose())
47
+ watch(
48
+ () => props.lang,
49
+ (v) => {
50
+ lang.value = pickLang(v, documentLang())
51
+ },
52
+ )
53
+
54
+ const query = ref('')
55
+ const queryShort = ref(false)
56
+ function search(): void {
57
+ const href = searchLink(routes.value.searchPath, query.value)
58
+ queryShort.value = href === null
59
+ if (href !== null && typeof location !== 'undefined') location.assign(href)
60
+ }
61
+
62
+ const failure = computed(() => {
63
+ const f = state?.failure
64
+ if (!f) return ''
65
+ const r = failureText(f, lang.value)
66
+ return t(r.key, r.params)
67
+ })
68
+ const countText = (c: ForumCategory): string =>
69
+ c.kind === 'article_discussions' ? t('forum.discussionCount', { count: c.topicCount }) : t('forum.topicCount', { count: c.topicCount })
70
+ </script>
71
+
72
+ <template>
73
+ <section v-if="ctl && state && state.phase !== 'hidden'" class="bcm bcm-forum" :lang="lang" :aria-labelledby="`${id}-title`">
74
+ <header class="bcm-head">
75
+ <h2 :id="`${id}-title`">{{ t('forum.title') }}</h2>
76
+ </header>
77
+
78
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('forum.loading') }}</p>
79
+ <MembersOnly
80
+ v-else-if="state.phase === 'membersOnly' && client"
81
+ :failure="state.failure"
82
+ :client="client"
83
+ :lang="lang"
84
+ :sign-in-href="signInHref"
85
+ :rules-href="routes.rulesPath"
86
+ @joined="ctl.load()"
87
+ />
88
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
89
+ <span>{{ failure }}</span>
90
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
91
+ </div>
92
+
93
+ <!-- An element, not a fragment: it holds a form, and happy-dom (the specs) answers a form's nextSibling with null,
94
+ which a fragment's removal walks. -->
95
+ <div v-else-if="state.phase === 'ready'" class="bcm-forum-body">
96
+ <ForumNotices :me="state.me" />
97
+
98
+ <form class="bcm-row" role="search" @submit.prevent="search">
99
+ <label :for="`${id}-q`" class="bcm-sr">{{ t('forum.search') }}</label>
100
+ <input :id="`${id}-q`" v-model="query" class="bcm-input bcm-grow" type="search" :placeholder="t('forum.search')" />
101
+ <button type="submit" class="bcm-btn">{{ t('forum.searchSubmit') }}</button>
102
+ <p v-if="queryShort" class="bcm-error bcm-full" role="alert">{{ t('search.tooShort', { min: COMMUNITY_SEARCH_QUERY_MIN }) }}</p>
103
+ </form>
104
+
105
+ <p v-if="state.categories.length === 0" class="bcm-muted">{{ t('forum.empty') }}</p>
106
+ <ul v-else class="bcm-list">
107
+ <li v-for="c in state.categories" :key="c.id" class="bcm-category">
108
+ <h3>
109
+ <a v-if="categoryPath(routes.categoryPath, c.slug)" :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ c.name }}</a>
110
+ <template v-else>{{ c.name }}</template>
111
+ </h3>
112
+ <p v-if="c.description" class="bcm-text">{{ c.description }}</p>
113
+ <p class="bcm-row bcm-muted">
114
+ <span>{{ countText(c) }}</span>
115
+ <time v-if="c.lastActivityAt" :datetime="c.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, c.lastActivityAt) }) }}</time>
116
+ </p>
117
+ <p v-if="c.mode === 'pre'" class="bcm-hint">{{ t('forum.modePre') }}</p>
118
+ <ul v-if="state.latest[c.id]?.length" class="bcm-topics" :aria-label="t('forum.latest')">
119
+ <TopicRow v-for="item in state.latest[c.id]" :key="item.conversationId" :item="item" :topic-href="routes.topicPath" :article-href="routes.articlePath" />
120
+ </ul>
121
+ <p v-if="categoryPath(routes.categoryPath, c.slug)">
122
+ <a :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ t('forum.allTopics') }}</a>
123
+ </p>
124
+ </li>
125
+ </ul>
126
+ </div>
127
+ </section>
128
+ </template>