@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
package/src/mount.ts CHANGED
@@ -7,13 +7,36 @@
7
7
  */
8
8
  import type { CommunityTransport } from '@blogmcp/sdk/community'
9
9
  import type { CommunityRoutes } from './core/attrs'
10
+ import type { DiscussionRealtime, TopicRealtime } from './core/realtime'
10
11
  import {
12
+ BADGE_ELEMENT_TAG,
11
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,
19
+ MEMBER_SETTINGS_ELEMENT_TAG,
12
20
  MY_COMMUNITY_ELEMENT_TAG,
13
21
  NOTICE_FORM_ELEMENT_TAG,
22
+ NOTIFICATION_CENTRE_ELEMENT_TAG,
23
+ PROFILE_ELEMENT_TAG,
24
+ TOPIC_ELEMENT_TAG,
25
+ WELCOME_ELEMENT_TAG,
26
+ defineBadgeElement,
14
27
  defineDiscussionElement,
28
+ defineFollowButtonElement,
29
+ defineForumCategoryElement,
30
+ defineForumElement,
31
+ defineForumModerationElement,
32
+ defineForumSearchElement,
33
+ defineMemberSettingsElement,
15
34
  defineMyCommunityElement,
16
35
  defineNoticeFormElement,
36
+ defineNotificationCentreElement,
37
+ defineProfileElement,
38
+ defineTopicElement,
39
+ defineWelcomeElement,
17
40
  } from './element'
18
41
 
19
42
  interface CommonMountOptions {
@@ -42,6 +65,11 @@ export interface MountDiscussionOptions extends CommonMountOptions {
42
65
  signInHref?: string
43
66
  /** Head poll interval in ms (default 20 000; 0 = off). */
44
67
  pollMs?: number
68
+ /**
69
+ * M2: live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime` (the host imports it; the element
70
+ * never does). Absent: the head poll.
71
+ */
72
+ realtime?: DiscussionRealtime
45
73
  }
46
74
 
47
75
  export interface MountNoticeFormOptions extends CommonMountOptions {
@@ -60,7 +88,79 @@ export interface MountMyCommunityOptions extends CommonMountOptions {
60
88
  signInHref?: string
61
89
  }
62
90
 
63
- type Settable = HTMLElement & { transport?: CommunityTransport }
91
+ export interface MountNotificationCentreOptions extends CommonMountOptions {
92
+ /** The site's sign-in page. */
93
+ signInHref?: string
94
+ }
95
+
96
+ export interface MountBadgeOptions extends CommonMountOptions {
97
+ /** The unread count's poll in ms (default 60 000; 0 = once, and when the page is shown again). */
98
+ pollMs?: number
99
+ }
100
+
101
+ export interface MountMemberSettingsOptions extends CommonMountOptions {
102
+ /** The host's service worker route (default `<site-base>/sw.js`, served from `@blogmcp/sdk/community/sw`). */
103
+ swPath?: string
104
+ /** The site's sign-in page. */
105
+ signInHref?: string
106
+ }
107
+
108
+ export interface MountProfileOptions extends CommonMountOptions {
109
+ /** The member's handle. */
110
+ handle: string
111
+ /** The site's sign-in page. */
112
+ signInHref?: string
113
+ }
114
+
115
+ export interface MountWelcomeOptions extends CommonMountOptions {
116
+ /** The site's sign-in page (a visitor's link in the profile's place). */
117
+ signInHref?: string
118
+ }
119
+
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 }
64
164
 
65
165
  function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
66
166
  for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
@@ -88,6 +188,7 @@ export function mountDiscussion(o: MountDiscussionOptions): () => void {
88
188
  'poll-ms': o.pollMs,
89
189
  nonce: o.nonce,
90
190
  })
191
+ if (o.realtime) el.realtime = o.realtime
91
192
  return place(el, o)
92
193
  }
93
194
 
@@ -125,3 +226,151 @@ export function mountMyCommunity(o: MountMyCommunityOptions = {}): () => void {
125
226
  })
126
227
  return place(el, o)
127
228
  }
229
+
230
+ /** Mount `<blogmcp-notification-centre>`; returns the function that removes it. */
231
+ export function mountNotificationCentre(o: MountNotificationCentreOptions = {}): () => void {
232
+ if (typeof document === 'undefined') return () => {}
233
+ defineNotificationCentreElement({ nonce: o.nonce })
234
+ const el = document.createElement(NOTIFICATION_CENTRE_ELEMENT_TAG) as Settable
235
+ 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 })
236
+ return place(el, o)
237
+ }
238
+
239
+ /** Mount `<blogmcp-badge>`; returns the function that removes it. */
240
+ export function mountBadge(o: MountBadgeOptions = {}): () => void {
241
+ if (typeof document === 'undefined') return () => {}
242
+ defineBadgeElement({ nonce: o.nonce })
243
+ const el = document.createElement(BADGE_ELEMENT_TAG) as Settable
244
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'poll-ms': o.pollMs, nonce: o.nonce })
245
+ return place(el, o)
246
+ }
247
+
248
+ /** Mount `<blogmcp-member-settings>`; returns the function that removes it. */
249
+ export function mountMemberSettings(o: MountMemberSettingsOptions = {}): () => void {
250
+ if (typeof document === 'undefined') return () => {}
251
+ defineMemberSettingsElement({ nonce: o.nonce })
252
+ const el = document.createElement(MEMBER_SETTINGS_ELEMENT_TAG) as Settable
253
+ attrs(el, {
254
+ 'api-base': o.apiBase,
255
+ 'site-base': o.siteBase,
256
+ lang: o.lang,
257
+ 'sw-path': o.swPath,
258
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
259
+ 'sign-in-href': o.signInHref,
260
+ nonce: o.nonce,
261
+ })
262
+ return place(el, o)
263
+ }
264
+
265
+ /** Mount `<blogmcp-profile>`; returns the function that removes it. */
266
+ export function mountProfile(o: MountProfileOptions): () => void {
267
+ if (typeof document === 'undefined') return () => {}
268
+ defineProfileElement({ nonce: o.nonce })
269
+ const el = document.createElement(PROFILE_ELEMENT_TAG) as Settable
270
+ attrs(el, {
271
+ handle: o.handle,
272
+ 'api-base': o.apiBase,
273
+ 'site-base': o.siteBase,
274
+ lang: o.lang,
275
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
276
+ 'sign-in-href': o.signInHref,
277
+ nonce: o.nonce,
278
+ })
279
+ return place(el, o)
280
+ }
281
+
282
+ /** Mount `<blogmcp-welcome>`; returns the function that removes it. */
283
+ export function mountWelcome(o: MountWelcomeOptions = {}): () => void {
284
+ if (typeof document === 'undefined') return () => {}
285
+ defineWelcomeElement({ nonce: o.nonce })
286
+ const el = document.createElement(WELCOME_ELEMENT_TAG) as Settable
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 })
288
+ return place(el, o)
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,5 +1,7 @@
1
1
  /**
2
- * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` 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
3
5
  * module is a client component): each mounts its element after hydration and removes it on unmount. Each
4
6
  * renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
5
7
  * sits where the component does and the page's markup is otherwise the site's alone.
@@ -7,11 +9,37 @@
7
9
  * <KbDiscussion articleId={article.id} apiBase="/api/community" signInHref={`/sign-in#${pathname}`} />
8
10
  */
9
11
  import { createElement, useEffect, useRef, type ReactElement } from 'react'
10
- import type { MountDiscussionOptions, MountMyCommunityOptions, MountNoticeFormOptions } from './mount'
12
+ import type {
13
+ MountBadgeOptions,
14
+ MountDiscussionOptions,
15
+ MountFollowButtonOptions,
16
+ MountForumCategoryOptions,
17
+ MountForumModerationOptions,
18
+ MountForumOptions,
19
+ MountForumSearchOptions,
20
+ MountMemberSettingsOptions,
21
+ MountMyCommunityOptions,
22
+ MountNoticeFormOptions,
23
+ MountNotificationCentreOptions,
24
+ MountProfileOptions,
25
+ MountTopicOptions,
26
+ MountWelcomeOptions,
27
+ } from './mount'
11
28
 
12
29
  export type KbDiscussionProps = Omit<MountDiscussionOptions, 'container'>
13
30
  export type KbNoticeFormProps = Omit<MountNoticeFormOptions, 'container'>
14
31
  export type KbMyCommunityProps = Omit<MountMyCommunityOptions, 'container'>
32
+ export type KbNotificationCentreProps = Omit<MountNotificationCentreOptions, 'container'>
33
+ export type KbBadgeProps = Omit<MountBadgeOptions, 'container'>
34
+ export type KbMemberSettingsProps = Omit<MountMemberSettingsOptions, 'container'>
35
+ export type KbProfileProps = Omit<MountProfileOptions, 'container'>
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'>
15
43
 
16
44
  /** The options that, changed, mount the element anew (functions by identity are left out). */
17
45
  const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
@@ -50,4 +78,63 @@ export function KbMyCommunity(props: KbMyCommunityProps): ReactElement {
50
78
  return useMounted('my-community', props, async () => (await import('./mount')).mountMyCommunity)
51
79
  }
52
80
 
53
- export type { MountDiscussionOptions, MountNoticeFormOptions, MountMyCommunityOptions }
81
+ export function KbNotificationCentre(props: KbNotificationCentreProps): ReactElement {
82
+ return useMounted('notification-centre', props, async () => (await import('./mount')).mountNotificationCentre)
83
+ }
84
+
85
+ export function KbBadge(props: KbBadgeProps): ReactElement {
86
+ return useMounted('badge', props, async () => (await import('./mount')).mountBadge)
87
+ }
88
+
89
+ export function KbMemberSettings(props: KbMemberSettingsProps): ReactElement {
90
+ return useMounted('member-settings', props, async () => (await import('./mount')).mountMemberSettings)
91
+ }
92
+
93
+ export function KbProfile(props: KbProfileProps): ReactElement {
94
+ return useMounted('profile', props, async () => (await import('./mount')).mountProfile)
95
+ }
96
+
97
+ export function KbWelcome(props: KbWelcomeProps): ReactElement {
98
+ return useMounted('welcome', props, async () => (await import('./mount')).mountWelcome)
99
+ }
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
+
125
+ export type {
126
+ MountDiscussionOptions,
127
+ MountNoticeFormOptions,
128
+ MountMyCommunityOptions,
129
+ MountNotificationCentreOptions,
130
+ MountBadgeOptions,
131
+ MountMemberSettingsOptions,
132
+ MountProfileOptions,
133
+ MountWelcomeOptions,
134
+ MountForumOptions,
135
+ MountForumCategoryOptions,
136
+ MountTopicOptions,
137
+ MountForumSearchOptions,
138
+ MountFollowButtonOptions,
139
+ MountForumModerationOptions,
140
+ }
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-badge>`: the unread count for a site's header (m2-contracts.md §3.3, §3.10) — a link to the notification
4
+ * centre showing the count (`99+` above 99), its accessible name the catalogue's „{count} nových upozornění". Nothing
5
+ * at all for a visitor, a reader who never joined, a dark module, or while there is nothing unread.
6
+ */
7
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
8
+ import type { CommunityTransport } from '@blogmcp/sdk/community'
9
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
10
+ import { badgeText, createBadge, type BadgeController } from '../core/badge'
11
+ import { communityClientFor, siteBaseFor } from '../core/client'
12
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
13
+ import { provideText } from './context'
14
+
15
+ defineOptions({ inheritAttrs: false })
16
+
17
+ const props = defineProps<{
18
+ apiBase?: string
19
+ siteBase?: string
20
+ lang?: string
21
+ /** JSON (or an object): `notificationsPath` (where the badge links). */
22
+ routes?: string | Record<string, unknown>
23
+ /** The poll in ms (default 60 000, never less; 0 = read once and when the page is shown again). */
24
+ pollMs?: number
25
+ transport?: CommunityTransport
26
+ }>()
27
+
28
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
29
+ const { t } = provideText(lang)
30
+
31
+ const siteBase = siteBaseFor(props.siteBase)
32
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
33
+ const ctl: BadgeController | null = client ? createBadge({ client, ...(typeof props.pollMs === 'number' ? { intervalMs: props.pollMs } : {}) }) : null
34
+ const state = ctl?.state ?? null
35
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
36
+
37
+ onMounted(() => void ctl?.start())
38
+ onBeforeUnmount(() => ctl?.dispose())
39
+ </script>
40
+
41
+ <template>
42
+ <a
43
+ v-if="state && state.phase === 'shown' && state.count > 0"
44
+ class="bcm-badge-link"
45
+ :lang="lang"
46
+ :href="routes.notificationsPath"
47
+ :aria-label="t('badge.unread', { count: state.count })"
48
+ :title="t('badge.unread', { count: state.count })"
49
+ ><span aria-hidden="true">{{ badgeText(state.count) }}</span></a
50
+ >
51
+ </template>
@@ -13,10 +13,11 @@ const refusedAges = new WeakMap<object, number>()
13
13
  * and one sentence while the discussion takes no writes yet.
14
14
  */
15
15
  import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
16
- import { signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
16
+ import { communityMessageAnchor, communityMessageSeqFromHash, signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
17
17
  import { parseRoutes, routesWithDefaults } from '../core/attrs'
18
18
  import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
19
19
  import { createDiscussionController, type DiscussionController } from '../core/discussion'
20
+ import type { DiscussionRealtime } from '../core/realtime'
20
21
  import { formatDate } from '../core/format'
21
22
  import { documentLang, pickLang, type Lang } from '../core/i18n'
22
23
  import { failureText } from '../core/outcomes'
@@ -46,6 +47,11 @@ const props = defineProps<{
46
47
  pollMs?: number
47
48
  /** A replacement transport (a property, never an attribute): tests, a signing transport later. */
48
49
  transport?: CommunityTransport
50
+ /**
51
+ * M2: live updates — the host's session factory (`createRealtimeSession` of `@blogmcp/sdk/community/realtime`), a
52
+ * property, never an attribute. Absent: the head poll.
53
+ */
54
+ realtime?: DiscussionRealtime
49
55
  }>()
50
56
 
51
57
  const host = useHost()
@@ -78,6 +84,7 @@ function create(): DiscussionController | null {
78
84
  articleId,
79
85
  lang: lang.value,
80
86
  pollMs: props.pollMs,
87
+ realtime: typeof props.realtime === 'function' ? props.realtime : null,
81
88
  refusedAge: (host && refusedAges.get(host)) ?? null,
82
89
  onAgeRefused: (minAge) => {
83
90
  if (host) refusedAges.set(host, minAge)
@@ -109,8 +116,30 @@ provideDiscussion({
109
116
  })
110
117
 
111
118
  let observer: IntersectionObserver | null = null
119
+ const section = ref<HTMLElement | null>(null)
120
+ /** The page's `#bcm-msg-<seq>` was looked for once this discussion's first page was in. */
121
+ let anchored = false
122
+
123
+ /**
124
+ * M2 T10: a link to one message (the notification centre's: `<article>#bcm-msg-<seq>`). The rows live in this shadow
125
+ * root, so the browser cannot scroll to the fragment: once the first page is in, the row is scrolled into view — a
126
+ * reply hidden behind „more replies" is shown first. A message beyond the first page is not looked for.
127
+ */
128
+ async function scrollToAnchor(): Promise<void> {
129
+ if (anchored || typeof location === 'undefined') return
130
+ anchored = true
131
+ const seq = communityMessageSeqFromHash(location.hash)
132
+ const id = seq === null ? null : communityMessageAnchor(seq)
133
+ if (seq === null || id === null) return
134
+ const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
135
+ if (parent) allReplies.add(parent.seq)
136
+ await nextTick()
137
+ const row = section.value?.querySelector<HTMLElement>(`#${id}`)
138
+ if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
139
+ }
112
140
 
113
141
  function start(): void {
142
+ anchored = false
114
143
  current.value?.dispose()
115
144
  opened.clear()
116
145
  originals.clear()
@@ -132,7 +161,7 @@ onMounted(() => {
132
161
  }
133
162
  })
134
163
  // Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
135
- watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.pollMs, lang.value] as const, () => start())
164
+ watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
136
165
  onBeforeUnmount(() => {
137
166
  observer?.disconnect()
138
167
  current.value?.dispose()
@@ -141,6 +170,12 @@ onBeforeUnmount(() => {
141
170
 
142
171
  const ctl = computed(() => current.value)
143
172
  const state = computed(() => current.value?.state ?? null)
173
+ watch(
174
+ () => state.value?.phase,
175
+ (phase) => {
176
+ if (phase === 'ready') void scrollToAnchor()
177
+ },
178
+ )
144
179
  const mode = computed(() => current.value?.mode() ?? 'pending')
145
180
  const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
146
181
  // Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
@@ -194,7 +229,7 @@ async function confirmDelete(): Promise<void> {
194
229
  </script>
195
230
 
196
231
  <template>
197
- <section v-if="ctl && state && state.phase !== 'hidden'" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
232
+ <section v-if="ctl && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
198
233
  <header class="bcm-head">
199
234
  <h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
200
235
  <span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
@@ -221,6 +256,8 @@ async function confirmDelete(): Promise<void> {
221
256
  <button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
222
257
  </div>
223
258
 
259
+ <p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
260
+
224
261
  <p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
225
262
  <ol v-else class="bcm-thread">
226
263
  <MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
@@ -231,6 +268,7 @@ async function confirmDelete(): Promise<void> {
231
268
  </div>
232
269
 
233
270
  <div class="bcm-section">
271
+ <p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
234
272
  <MessageComposer
235
273
  v-if="mode === 'write'"
236
274
  ref="composer"
@@ -238,6 +276,7 @@ async function confirmDelete(): Promise<void> {
238
276
  :draft="state.composer"
239
277
  :wait="ctl.waitLeft(state.composer)"
240
278
  :rules-href="routes.rulesPath"
279
+ :mentions-max="ctl.mentionCap()"
241
280
  @submit="ctl.submit()"
242
281
  @confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
243
282
  @confirmed="ctl.sendConfirmed(state.composer)"
@@ -261,6 +300,7 @@ async function confirmDelete(): Promise<void> {
261
300
  </p>
262
301
  </div>
263
302
  <p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
303
+ <p v-else-if="mode === 'braked'" class="bcm-note" role="status">{{ t('state.braked') }}</p>
264
304
  <p v-else-if="mode === 'restricted'" class="bcm-note">
265
305
  {{ t('error.restricted') }}
266
306
  <a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
@@ -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>