@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.
- package/README.md +64 -12
- package/dist/blogmcp-community.global.js +7 -7
- package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
- package/dist/core/attrs.d.ts +17 -2
- package/dist/core/discussion.d.ts +92 -18
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +7 -0
- package/dist/core/profile.d.ts +6 -1
- package/dist/core/realtime.d.ts +13 -2
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +26 -1
- package/dist/global.d.ts +7 -6
- package/dist/index.d.ts +13 -4
- package/dist/index.js +54 -27
- package/dist/mount.d.ts +50 -1
- package/dist/react.d.ts +17 -4
- package/dist/react.js +32 -8
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +6 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +164 -5
- package/dist/vue.js +134 -8
- package/package.json +3 -3
- package/src/core/attrs.ts +22 -2
- package/src/core/discussion.ts +309 -43
- package/src/core/follow.ts +139 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +16 -4
- package/src/core/outcomes.ts +10 -0
- package/src/core/profile.ts +12 -1
- package/src/core/realtime.ts +15 -2
- package/src/core/topic.ts +234 -0
- package/src/element.ts +63 -1
- package/src/global.ts +27 -5
- package/src/index.ts +49 -2
- package/src/mount.ts +144 -2
- package/src/react.ts +45 -2
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +51 -9
- package/src/ui/MessageItem.vue +12 -1
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +3 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +37 -3
- package/src/ui/ProfileView.vue +15 -4
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +14 -0
- 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
|
-
|
|
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
|
|
3
|
-
* `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>`
|
|
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>
|