@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.
- package/README.md +162 -13
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/mount--2_V2q3M.js +10063 -0
- package/dist/core/attrs.d.ts +42 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +120 -15
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/format.d.ts +7 -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 +49 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +45 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +46 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +47 -1
- package/dist/global.d.ts +8 -4
- package/dist/index.d.ts +20 -3
- package/dist/index.js +62 -12
- package/dist/mount.d.ts +89 -0
- package/dist/react.d.ts +27 -3
- package/dist/react.js +48 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- 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/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +8 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +273 -3
- package/dist/vue.js +235 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +61 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +497 -46
- package/src/core/follow.ts +139 -0
- package/src/core/format.ts +22 -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 +177 -18
- package/src/core/outcomes.ts +23 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +155 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +52 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/core/topic.ts +234 -0
- package/src/element.ts +124 -6
- package/src/global.ts +40 -4
- package/src/index.ts +84 -2
- package/src/mount.ts +250 -1
- package/src/react.ts +90 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- 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/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +64 -11
- package/src/ui/MessageItem.vue +36 -7
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +12 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +193 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +102 -0
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +30 -0
- package/src/vue.ts +271 -3
- 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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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>
|