@blogmcp/community 0.1.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 (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The follow button (m3-contracts.md §3.3, §4.3; B16): a toggle — „Sledovat", or pressed „Sledujete" — over a follow
4
+ * controller. A follow kb-api refuses as `notFound` (the member may not read the target now) says „not available" once
5
+ * and offers nothing more; an unfollow never fails on its target. Nothing for a viewer who cannot follow (a visitor), or
6
+ * while the state is not known.
7
+ */
8
+ import { ref, watch } from 'vue'
9
+ import type { FollowController } from '../core/follow'
10
+ import { useText } from './context'
11
+
12
+ const props = defineProps<{ ctl: FollowController }>()
13
+ const { t, text } = useText()
14
+
15
+ const live = ref('')
16
+ watch(
17
+ () => props.ctl.state.announceId,
18
+ () => {
19
+ live.value = text(props.ctl.state.announcement)
20
+ },
21
+ )
22
+ </script>
23
+
24
+ <template>
25
+ <span class="bcm-follow">
26
+ <button
27
+ v-if="ctl.state.phase === 'ready'"
28
+ type="button"
29
+ class="bcm-btn bcm-small"
30
+ :aria-pressed="ctl.state.following"
31
+ :disabled="ctl.state.busy"
32
+ @click="ctl.toggle()"
33
+ >
34
+ {{ ctl.state.following ? t('follow.following') : t('follow.follow') }}
35
+ </button>
36
+ <span v-else-if="ctl.state.phase === 'unavailable'" class="bcm-muted">{{ t('follow.unavailable') }}</span>
37
+ <span class="bcm-sr" aria-live="polite">{{ live }}</span>
38
+ </span>
39
+ </template>
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-forum-category slug>` (m3-contracts.md §3.2, §3.3, §5.3): a category's page — its name, description and
4
+ * follow button, its topics in the activity or the newest order (pinned first, archived last; „load more" with the
5
+ * forum's cursor) and, where the category takes topics from members, the new-topic composer: the title and the opening
6
+ * post, with the link to the rules every composer carries (B21). A topic waiting for review says so and shows in the
7
+ * list to its author („čeká na kontrolu"). In „Články" the rows are the article discussions, each with its follow
8
+ * button. The element dispatches `bcm-topic-created` (`{ conversationId, state }`) when the member opens a topic.
9
+ */
10
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useHost, watch } from 'vue'
11
+ import {
12
+ followKeyOf,
13
+ topicPath,
14
+ type CommunityTransport,
15
+ type ForumSort,
16
+ type KnownTopicItem,
17
+ } from '@blogmcp/sdk/community'
18
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
19
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
20
+ import { createCategory, type CategoryController } from '../core/forum-category'
21
+ import { createFollow, type FollowController } from '../core/follow'
22
+ import { forumNoticesOf, pausedKey } from '../core/forum-state'
23
+ import { FORUM_WORDS } from '../core/forum-words'
24
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
25
+ import { failureText } from '../core/outcomes'
26
+ import FollowToggle from './FollowToggle.vue'
27
+ import ForumNotices from './ForumNotices.vue'
28
+ import MembersOnly from './MembersOnly.vue'
29
+ import MessageComposer from './MessageComposer.vue'
30
+ import OnboardingSteps from './OnboardingSteps.vue'
31
+ import TopicRow from './TopicRow.vue'
32
+ import { provideText, uid } from './context'
33
+
34
+ defineOptions({ inheritAttrs: false })
35
+
36
+ const props = defineProps<{
37
+ /** The category's slug (`les-a-sber`). */
38
+ slug?: string
39
+ apiBase?: string
40
+ siteBase?: string
41
+ lang?: string
42
+ routes?: string | Record<string, unknown>
43
+ signInHref?: string
44
+ transport?: CommunityTransport
45
+ }>()
46
+
47
+ const host = useHost()
48
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
49
+ // The new topic's composer reads the forum's words („příspěvek", not „komentář").
50
+ const { t, text } = provideText(lang, FORUM_WORDS)
51
+ const id = uid('bcm-category')
52
+
53
+ const siteBase = siteBaseFor(props.siteBase)
54
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
55
+
56
+ function create(): CategoryController | null {
57
+ const slug = props.slug?.trim()
58
+ if (!slug) {
59
+ warnOnce('category-slug', '[blogmcp-community] <blogmcp-forum-category> needs a slug')
60
+ return null
61
+ }
62
+ if (!client) return null
63
+ return createCategory({
64
+ client,
65
+ slug,
66
+ lang: lang.value,
67
+ onCreated: (topic) =>
68
+ host?.dispatchEvent(new CustomEvent('bcm-topic-created', { detail: { conversationId: topic.conversationId, state: topic.held ? 'held' : 'visible' }, bubbles: true, composed: true })),
69
+ })
70
+ }
71
+
72
+ // A shallow ref: the controller's own refs (its onboarding) stay refs, as the discussion's do.
73
+ const ctl = shallowRef<CategoryController | null>(null)
74
+ const state = computed(() => ctl.value?.state ?? null)
75
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state.value?.me?.links, siteBase ?? ''))
76
+
77
+ /** The category's follow button, and one per article discussion of „Články" (by its conversation). */
78
+ const categoryFollow = shallowRef<FollowController | null>(null)
79
+ // Not reactive: a row's button is made while the list renders (its state is reactive by itself).
80
+ const rowFollows = new Map<string, FollowController>()
81
+
82
+ function disposeFollows(): void {
83
+ categoryFollow.value?.dispose()
84
+ categoryFollow.value = null
85
+ for (const f of rowFollows.values()) f.dispose()
86
+ rowFollows.clear()
87
+ }
88
+
89
+ function start(): void {
90
+ ctl.value?.dispose()
91
+ disposeFollows()
92
+ ctl.value = create()
93
+ void ctl.value?.load()
94
+ }
95
+
96
+ onMounted(start)
97
+ watch(() => [props.slug, lang.value] as const, start)
98
+ watch(
99
+ () => props.lang,
100
+ (v) => {
101
+ lang.value = pickLang(v, documentLang())
102
+ },
103
+ )
104
+ onBeforeUnmount(() => {
105
+ ctl.value?.dispose()
106
+ disposeFollows()
107
+ })
108
+
109
+ // The follow buttons follow what the answers say.
110
+ watch(
111
+ () => state.value?.category,
112
+ (category) => {
113
+ if (!category || !client) return
114
+ if (!categoryFollow.value) categoryFollow.value = createFollow({ client, target: { categoryId: category.id }, lang: lang.value })
115
+ if (categoryFollow.value.state.phase === 'idle') void categoryFollow.value.load(category.following)
116
+ else categoryFollow.value.sync(category.following)
117
+ },
118
+ )
119
+ // An article discussion's button is made when its row arrives (before the list renders: a pre-flush watcher).
120
+ watch(
121
+ () => state.value?.items,
122
+ (items) => {
123
+ if (!items || !client) return
124
+ for (const item of items) {
125
+ if (item.kind !== 'article') continue
126
+ const key = followKeyOf({ conversationId: item.conversationId })
127
+ if (key === null || rowFollows.has(key)) continue
128
+ const f = createFollow({ client, target: { conversationId: item.conversationId }, lang: lang.value })
129
+ rowFollows.set(key, f)
130
+ void f.load(item.following)
131
+ }
132
+ },
133
+ )
134
+ function rowFollow(item: KnownTopicItem): FollowController | null {
135
+ if (item.kind !== 'article') return null
136
+ const key = followKeyOf({ conversationId: item.conversationId })
137
+ return key === null ? null : (rowFollows.get(key) ?? null)
138
+ }
139
+
140
+ const name = computed(() => state.value?.category?.name ?? state.value?.header?.name ?? '')
141
+ const isArticles = computed(() => (state.value?.category?.kind ?? state.value?.header?.kind) === 'article_discussions')
142
+ const notCreating = computed(() => ctl.value?.notCreating() ?? null)
143
+ /** A pause a refused write named, while me/get names none (ForumNotices says the one me/get knows). */
144
+ const refusedPause = computed(() => (state.value && forumNoticesOf(state.value.me).paused === null ? pausedKey(state.value.brakedBy) : null))
145
+ const createdHref = computed(() => (state.value?.created ? topicPath(routes.value.topicPath, state.value.created.conversationId) : null))
146
+ const failure = computed(() => {
147
+ const f = state.value?.failure
148
+ if (!f) return ''
149
+ const r = failureText(f, lang.value)
150
+ return t(r.key, r.params)
151
+ })
152
+
153
+ function onSort(e: Event): void {
154
+ void ctl.value?.setSort((e.target as HTMLSelectElement).value as ForumSort)
155
+ }
156
+
157
+ const composerRef = ref<InstanceType<typeof MessageComposer> | null>(null)
158
+ // One live region, present from the start; the same outcome twice is announced twice (cleared in between).
159
+ const liveText = ref('')
160
+ watch(
161
+ () => state.value?.announceId,
162
+ () => {
163
+ liveText.value = ''
164
+ const next = text(state.value?.announcement)
165
+ void nextTick(() => {
166
+ liveText.value = next
167
+ })
168
+ },
169
+ )
170
+ </script>
171
+
172
+ <template>
173
+ <section v-if="ctl && state && state.phase !== 'hidden'" class="bcm bcm-forum-category" :lang="lang" :aria-labelledby="`${id}-title`">
174
+ <nav class="bcm-row">
175
+ <a :href="routes.forumPath">{{ t('forum.back') }}</a>
176
+ </nav>
177
+
178
+ <p v-if="state.phase === 'loading'" :id="`${id}-title`" class="bcm-muted" role="status">{{ t('forum.loading') }}</p>
179
+ <div v-else-if="state.phase === 'membersOnly' && client" :id="`${id}-title`">
180
+ <MembersOnly :failure="state.failure" :client="client" :lang="lang" :sign-in-href="signInHref" :rules-href="routes.rulesPath" @joined="ctl.load()" />
181
+ </div>
182
+ <p v-else-if="state.phase === 'notFound'" :id="`${id}-title`" class="bcm-note">{{ t('forumCategory.notFound') }}</p>
183
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
184
+ <span :id="`${id}-title`">{{ failure }}</span>
185
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
186
+ </div>
187
+
188
+ <template v-else-if="state.phase === 'ready'">
189
+ <header class="bcm-head">
190
+ <h2 :id="`${id}-title`">{{ name }}</h2>
191
+ <FollowToggle v-if="categoryFollow" :ctl="categoryFollow" />
192
+ </header>
193
+ <p v-if="state.category?.description" class="bcm-text">{{ state.category.description }}</p>
194
+ <ForumNotices :me="state.me" />
195
+ <p v-if="state.category?.mode === 'pre'" class="bcm-hint">{{ t('forum.modePre') }}</p>
196
+
197
+ <div v-if="!isArticles" class="bcm-row">
198
+ <label :for="`${id}-sort`">{{ t('forumCategory.sort') }}</label>
199
+ <select :id="`${id}-sort`" class="bcm-input bcm-select" :value="state.sort" :disabled="state.sorting" @change="onSort">
200
+ <option value="activity">{{ t('forumCategory.sortActivity') }}</option>
201
+ <option value="new">{{ t('forumCategory.sortNew') }}</option>
202
+ </select>
203
+ </div>
204
+
205
+ <p v-if="state.items.length === 0" class="bcm-muted">{{ isArticles ? t('forumCategory.articlesEmpty') : t('forumCategory.empty') }}</p>
206
+ <ul v-else class="bcm-topics" :aria-busy="state.sorting">
207
+ <TopicRow v-for="item in state.items" :key="item.conversationId" :item="item" :topic-href="routes.topicPath" :article-href="routes.articlePath" :follow="rowFollow(item)" />
208
+ </ul>
209
+ <div v-if="state.nextCursor !== null" class="bcm-row">
210
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
211
+ </div>
212
+
213
+ <section v-if="!isArticles" class="bcm-section" :aria-labelledby="`${id}-new`">
214
+ <!-- No role="status": the element's live region announces it (once). -->
215
+ <p v-if="state.created" class="bcm-note">
216
+ {{ state.created.held ? t('topic.createdHeld') : t('topic.created') }}
217
+ <a v-if="createdHref" :href="createdHref">{{ t('topic.open') }}</a>
218
+ </p>
219
+ <!-- An element, not a fragment: the composer is a form, and happy-dom (the specs) answers a form's nextSibling
220
+ with null, which a fragment's removal walks. -->
221
+ <div v-if="ctl.canCreate()" class="bcm-new-topic">
222
+ <h3 :id="`${id}-new`">{{ t('forumCategory.newTopic') }}</h3>
223
+ <OnboardingSteps v-if="ctl.onboarding.value" :model="ctl.onboarding.value" :rules-href="routes.rulesPath" @cancel="ctl.cancelOnboarding()" />
224
+ <MessageComposer
225
+ v-else
226
+ ref="composerRef"
227
+ kind="topic"
228
+ :draft="state.composer"
229
+ :wait="ctl.waitLeft()"
230
+ :rules-href="routes.rulesPath"
231
+ @submit="ctl.create()"
232
+ @confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(code, checked)"
233
+ @confirmed="ctl.sendConfirmed()"
234
+ />
235
+ </div>
236
+ <p v-else-if="notCreating === 'team'" class="bcm-hint">{{ t('forum.teamTopics') }}</p>
237
+ <p v-else-if="notCreating === 'paused' && refusedPause" class="bcm-note" role="status">{{ t(refusedPause) }}</p>
238
+ </section>
239
+ </template>
240
+
241
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
242
+ </section>
243
+ </template>
@@ -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>