@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
package/src/mount.ts CHANGED
@@ -7,23 +7,35 @@
7
7
  */
8
8
  import type { CommunityTransport } from '@blogmcp/sdk/community'
9
9
  import type { CommunityRoutes } from './core/attrs'
10
- import type { DiscussionRealtime } from './core/realtime'
10
+ import type { DiscussionRealtime, TopicRealtime } from './core/realtime'
11
11
  import {
12
12
  BADGE_ELEMENT_TAG,
13
13
  DISCUSSION_ELEMENT_TAG,
14
+ FOLLOW_BUTTON_ELEMENT_TAG,
15
+ FORUM_CATEGORY_ELEMENT_TAG,
16
+ FORUM_ELEMENT_TAG,
17
+ FORUM_MODERATION_ELEMENT_TAG,
18
+ FORUM_SEARCH_ELEMENT_TAG,
14
19
  MEMBER_SETTINGS_ELEMENT_TAG,
15
20
  MY_COMMUNITY_ELEMENT_TAG,
16
21
  NOTICE_FORM_ELEMENT_TAG,
17
22
  NOTIFICATION_CENTRE_ELEMENT_TAG,
18
23
  PROFILE_ELEMENT_TAG,
24
+ TOPIC_ELEMENT_TAG,
19
25
  WELCOME_ELEMENT_TAG,
20
26
  defineBadgeElement,
21
27
  defineDiscussionElement,
28
+ defineFollowButtonElement,
29
+ defineForumCategoryElement,
30
+ defineForumElement,
31
+ defineForumModerationElement,
32
+ defineForumSearchElement,
22
33
  defineMemberSettingsElement,
23
34
  defineMyCommunityElement,
24
35
  defineNoticeFormElement,
25
36
  defineNotificationCentreElement,
26
37
  defineProfileElement,
38
+ defineTopicElement,
27
39
  defineWelcomeElement,
28
40
  } from './element'
29
41
 
@@ -105,7 +117,50 @@ export interface MountWelcomeOptions extends CommonMountOptions {
105
117
  signInHref?: string
106
118
  }
107
119
 
108
- type Settable = HTMLElement & { transport?: CommunityTransport; realtime?: DiscussionRealtime }
120
+ /** M3 (m3-contracts.md §5.3): the forum's index — its categories, their latest topics, the search box. */
121
+ export interface MountForumOptions extends CommonMountOptions {
122
+ /** The site's sign-in page (the members-only sentence's link for a visitor). */
123
+ signInHref?: string
124
+ }
125
+
126
+ /** M3: one category's page — its topics, its follow button, the new-topic composer. */
127
+ export interface MountForumCategoryOptions extends CommonMountOptions {
128
+ /** The category's slug. */
129
+ slug: string
130
+ signInHref?: string
131
+ }
132
+
133
+ /** M3: one topic's page — the discussion's view over the topic, with its header. */
134
+ export interface MountTopicOptions extends CommonMountOptions {
135
+ /** The topic's conversation id. */
136
+ conversationId: string
137
+ signInHref?: string
138
+ /** Head poll interval in ms (default 20 000; 0 = off). */
139
+ pollMs?: number
140
+ /** Live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime` (the host imports it). Absent: the head poll. */
141
+ realtime?: TopicRealtime
142
+ }
143
+
144
+ /** M3: the forum's search. */
145
+ export interface MountForumSearchOptions extends CommonMountOptions {
146
+ signInHref?: string
147
+ }
148
+
149
+ /** M3: a follow button — a topic or an article discussion (`conversation`) or a category, by its id. */
150
+ export interface MountFollowButtonOptions extends CommonMountOptions {
151
+ targetKind: 'conversation' | 'category'
152
+ targetId: string
153
+ /** Whether the member follows it, when the host knows (absent: the member's follows are read). */
154
+ following?: boolean
155
+ }
156
+
157
+ /** M3: a category moderator's queue (renders nothing for a member who moderates nothing). */
158
+ export interface MountForumModerationOptions extends CommonMountOptions {
159
+ /** Narrow the queue to one of the moderator's categories. */
160
+ categoryId?: string
161
+ }
162
+
163
+ type Settable = HTMLElement & { transport?: CommunityTransport; realtime?: DiscussionRealtime | TopicRealtime }
109
164
 
110
165
  function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
111
166
  for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
@@ -232,3 +287,90 @@ export function mountWelcome(o: MountWelcomeOptions = {}): () => void {
232
287
  attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'sign-in-href': o.signInHref, nonce: o.nonce })
233
288
  return place(el, o)
234
289
  }
290
+
291
+ /** M3: mount `<blogmcp-forum>`; returns the function that removes it. */
292
+ export function mountForum(o: MountForumOptions = {}): () => void {
293
+ if (typeof document === 'undefined') return () => {}
294
+ defineForumElement({ nonce: o.nonce })
295
+ const el = document.createElement(FORUM_ELEMENT_TAG) as Settable
296
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'sign-in-href': o.signInHref, nonce: o.nonce })
297
+ return place(el, o)
298
+ }
299
+
300
+ /** M3: mount `<blogmcp-forum-category>`; returns the function that removes it. */
301
+ export function mountForumCategory(o: MountForumCategoryOptions): () => void {
302
+ if (typeof document === 'undefined') return () => {}
303
+ defineForumCategoryElement({ nonce: o.nonce })
304
+ const el = document.createElement(FORUM_CATEGORY_ELEMENT_TAG) as Settable
305
+ attrs(el, {
306
+ slug: o.slug,
307
+ 'api-base': o.apiBase,
308
+ 'site-base': o.siteBase,
309
+ lang: o.lang,
310
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
311
+ 'sign-in-href': o.signInHref,
312
+ nonce: o.nonce,
313
+ })
314
+ return place(el, o)
315
+ }
316
+
317
+ /** M3: mount `<blogmcp-topic>`; returns the function that removes it. */
318
+ export function mountTopic(o: MountTopicOptions): () => void {
319
+ if (typeof document === 'undefined') return () => {}
320
+ defineTopicElement({ nonce: o.nonce })
321
+ const el = document.createElement(TOPIC_ELEMENT_TAG) as Settable
322
+ attrs(el, {
323
+ 'conversation-id': o.conversationId,
324
+ 'api-base': o.apiBase,
325
+ 'site-base': o.siteBase,
326
+ lang: o.lang,
327
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
328
+ 'sign-in-href': o.signInHref,
329
+ 'poll-ms': o.pollMs,
330
+ nonce: o.nonce,
331
+ })
332
+ if (o.realtime) el.realtime = o.realtime
333
+ return place(el, o)
334
+ }
335
+
336
+ /** M3: mount `<blogmcp-forum-search>`; returns the function that removes it. */
337
+ export function mountForumSearch(o: MountForumSearchOptions = {}): () => void {
338
+ if (typeof document === 'undefined') return () => {}
339
+ defineForumSearchElement({ nonce: o.nonce })
340
+ const el = document.createElement(FORUM_SEARCH_ELEMENT_TAG) as Settable
341
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'sign-in-href': o.signInHref, nonce: o.nonce })
342
+ return place(el, o)
343
+ }
344
+
345
+ /** M3: mount `<blogmcp-follow-button>`; returns the function that removes it. */
346
+ export function mountFollowButton(o: MountFollowButtonOptions): () => void {
347
+ if (typeof document === 'undefined') return () => {}
348
+ defineFollowButtonElement({ nonce: o.nonce })
349
+ const el = document.createElement(FOLLOW_BUTTON_ELEMENT_TAG) as Settable
350
+ attrs(el, {
351
+ 'target-kind': o.targetKind,
352
+ 'target-id': o.targetId,
353
+ following: typeof o.following === 'boolean' ? String(o.following) : undefined,
354
+ 'api-base': o.apiBase,
355
+ 'site-base': o.siteBase,
356
+ lang: o.lang,
357
+ nonce: o.nonce,
358
+ })
359
+ return place(el, o)
360
+ }
361
+
362
+ /** M3: mount `<blogmcp-forum-moderation>`; returns the function that removes it. */
363
+ export function mountForumModeration(o: MountForumModerationOptions = {}): () => void {
364
+ if (typeof document === 'undefined') return () => {}
365
+ defineForumModerationElement({ nonce: o.nonce })
366
+ const el = document.createElement(FORUM_MODERATION_ELEMENT_TAG) as Settable
367
+ attrs(el, {
368
+ 'category-id': o.categoryId,
369
+ 'api-base': o.apiBase,
370
+ 'site-base': o.siteBase,
371
+ lang: o.lang,
372
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
373
+ nonce: o.nonce,
374
+ })
375
+ return place(el, o)
376
+ }
package/src/react.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` and (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
- * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` for React (18 and 19, Next.js App Router included — this
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`, (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
+ * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` and (M3) `<KbForum>`, `<KbForumCategory>`, `<KbTopic>`,
4
+ * `<KbForumSearch>`, `<KbFollowButton>`, `<KbForumModeration>` for React (18 and 19, Next.js App Router included — this
4
5
  * module is a client component): each mounts its element after hydration and removes it on unmount. Each
5
6
  * renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
6
7
  * sits where the component does and the page's markup is otherwise the site's alone.
@@ -11,11 +12,17 @@ import { createElement, useEffect, useRef, type ReactElement } from 'react'
11
12
  import type {
12
13
  MountBadgeOptions,
13
14
  MountDiscussionOptions,
15
+ MountFollowButtonOptions,
16
+ MountForumCategoryOptions,
17
+ MountForumModerationOptions,
18
+ MountForumOptions,
19
+ MountForumSearchOptions,
14
20
  MountMemberSettingsOptions,
15
21
  MountMyCommunityOptions,
16
22
  MountNoticeFormOptions,
17
23
  MountNotificationCentreOptions,
18
24
  MountProfileOptions,
25
+ MountTopicOptions,
19
26
  MountWelcomeOptions,
20
27
  } from './mount'
21
28
 
@@ -27,6 +34,12 @@ export type KbBadgeProps = Omit<MountBadgeOptions, 'container'>
27
34
  export type KbMemberSettingsProps = Omit<MountMemberSettingsOptions, 'container'>
28
35
  export type KbProfileProps = Omit<MountProfileOptions, 'container'>
29
36
  export type KbWelcomeProps = Omit<MountWelcomeOptions, 'container'>
37
+ export type KbForumProps = Omit<MountForumOptions, 'container'>
38
+ export type KbForumCategoryProps = Omit<MountForumCategoryOptions, 'container'>
39
+ export type KbTopicProps = Omit<MountTopicOptions, 'container'>
40
+ export type KbForumSearchProps = Omit<MountForumSearchOptions, 'container'>
41
+ export type KbFollowButtonProps = Omit<MountFollowButtonOptions, 'container'>
42
+ export type KbForumModerationProps = Omit<MountForumModerationOptions, 'container'>
30
43
 
31
44
  /** The options that, changed, mount the element anew (functions by identity are left out). */
32
45
  const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
@@ -85,6 +98,30 @@ export function KbWelcome(props: KbWelcomeProps): ReactElement {
85
98
  return useMounted('welcome', props, async () => (await import('./mount')).mountWelcome)
86
99
  }
87
100
 
101
+ export function KbForum(props: KbForumProps): ReactElement {
102
+ return useMounted('forum', props, async () => (await import('./mount')).mountForum)
103
+ }
104
+
105
+ export function KbForumCategory(props: KbForumCategoryProps): ReactElement {
106
+ return useMounted('forum-category', props, async () => (await import('./mount')).mountForumCategory)
107
+ }
108
+
109
+ export function KbTopic(props: KbTopicProps): ReactElement {
110
+ return useMounted('topic', props, async () => (await import('./mount')).mountTopic)
111
+ }
112
+
113
+ export function KbForumSearch(props: KbForumSearchProps): ReactElement {
114
+ return useMounted('forum-search', props, async () => (await import('./mount')).mountForumSearch)
115
+ }
116
+
117
+ export function KbFollowButton(props: KbFollowButtonProps): ReactElement {
118
+ return useMounted('follow-button', props, async () => (await import('./mount')).mountFollowButton)
119
+ }
120
+
121
+ export function KbForumModeration(props: KbForumModerationProps): ReactElement {
122
+ return useMounted('forum-moderation', props, async () => (await import('./mount')).mountForumModeration)
123
+ }
124
+
88
125
  export type {
89
126
  MountDiscussionOptions,
90
127
  MountNoticeFormOptions,
@@ -94,4 +131,10 @@ export type {
94
131
  MountMemberSettingsOptions,
95
132
  MountProfileOptions,
96
133
  MountWelcomeOptions,
134
+ MountForumOptions,
135
+ MountForumCategoryOptions,
136
+ MountTopicOptions,
137
+ MountForumSearchOptions,
138
+ MountFollowButtonOptions,
139
+ MountForumModerationOptions,
97
140
  }
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-follow-button target-kind target-id>` (m3-contracts.md §3.3, §4.3, §5.3; D-M3-4): follow a topic or an
4
+ * article discussion (`target-kind="conversation"`, its conversation id) or a category (`category`, its id) — its new
5
+ * messages, or its new topics, come as notifications. The state starts from the host's `following` attribute when it
6
+ * knows it, else from the member's follows. A follow kb-api refuses as `notFound` reads „not available"; an unfollow
7
+ * never fails on its target. Renders nothing for a viewer who cannot follow (a visitor) and for a target that is none.
8
+ */
9
+ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
10
+ import { followTargetOf, type CommunityTransport } from '@blogmcp/sdk/community'
11
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
12
+ import { createFollow, type FollowController } from '../core/follow'
13
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
14
+ import FollowToggle from './FollowToggle.vue'
15
+ import { provideText } from './context'
16
+
17
+ defineOptions({ inheritAttrs: false })
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** `conversation` (a topic, an article discussion) or `category`. */
22
+ targetKind?: string
23
+ /** The conversation's or the category's id. */
24
+ targetId?: string
25
+ /** `true` / `false` when the host knows whether the member follows it (absent: the member's follows are read). */
26
+ following?: string | boolean
27
+ apiBase?: string
28
+ siteBase?: string
29
+ lang?: string
30
+ transport?: CommunityTransport
31
+ }>(),
32
+ // An explicit default: without one Vue casts an absent boolean prop to `false`, and „absent" means „not known".
33
+ { following: undefined },
34
+ )
35
+
36
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
37
+ provideText(lang)
38
+
39
+ const ctl = shallowRef<FollowController | null>(null)
40
+
41
+ function known(): boolean | null {
42
+ const f = props.following
43
+ if (f === true || f === 'true' || f === '') return true
44
+ if (f === false || f === 'false') return false
45
+ return null
46
+ }
47
+
48
+ function start(): void {
49
+ ctl.value?.dispose()
50
+ ctl.value = null
51
+ const target = followTargetOf(props.targetKind, props.targetId)
52
+ if (target === null) {
53
+ warnOnce('follow-target', '[blogmcp-community] <blogmcp-follow-button> needs target-kind ("conversation" or "category") and a target-id')
54
+ return
55
+ }
56
+ const siteBase = siteBaseFor(props.siteBase)
57
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
58
+ if (!client) return
59
+ ctl.value = createFollow({ client, target, lang: lang.value })
60
+ void ctl.value.load(known())
61
+ }
62
+
63
+ onMounted(start)
64
+ watch(() => [props.targetKind, props.targetId, props.apiBase, props.siteBase, props.transport] as const, start)
65
+ watch(
66
+ () => props.following,
67
+ () => {
68
+ const k = known()
69
+ if (k !== null) ctl.value?.sync(k)
70
+ },
71
+ )
72
+ watch(
73
+ () => props.lang,
74
+ (v) => {
75
+ lang.value = pickLang(v, documentLang())
76
+ },
77
+ )
78
+ onBeforeUnmount(() => ctl.value?.dispose())
79
+ </script>
80
+
81
+ <template>
82
+ <div v-if="ctl && (ctl.state.phase === 'ready' || ctl.state.phase === 'unavailable')" class="bcm-follow-host" :lang="lang">
83
+ <FollowToggle :ctl="ctl" />
84
+ </div>
85
+ </template>
@@ -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>