@blogmcp/community 0.1.0 → 0.2.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 +108 -11
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
- package/dist/core/attrs.d.ts +27 -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 +36 -5
- package/dist/core/format.d.ts +7 -0
- package/dist/core/onboarding.d.ts +42 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/element.d.ts +22 -1
- package/dist/global.d.ts +7 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.js +34 -11
- package/dist/mount.d.ts +40 -0
- package/dist/react.d.ts +14 -3
- package/dist/react.js +24 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +2 -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/WelcomeView.vue.d.ts +12 -0
- package/dist/vue.d.ts +114 -3
- package/dist/vue.js +109 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +41 -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 +203 -18
- package/src/core/format.ts +22 -0
- package/src/core/onboarding.ts +165 -18
- package/src/core/outcomes.ts +13 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/element.ts +62 -6
- package/src/global.ts +18 -4
- package/src/index.ts +36 -1
- package/src/mount.ts +108 -1
- package/src/react.ts +47 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +15 -4
- package/src/ui/MessageItem.vue +24 -6
- package/src/ui/MyCommunityView.vue +9 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/styles.css +16 -0
- package/src/vue.ts +129 -3
package/src/element.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/// <reference path="./shims.d.ts" />
|
|
2
2
|
/**
|
|
3
|
-
* The
|
|
3
|
+
* The custom elements (Vue inside a shadow root each), m1-contracts.md §10.4 and m2-contracts.md §3.10:
|
|
4
4
|
*
|
|
5
|
-
* <blogmcp-discussion>
|
|
6
|
-
*
|
|
7
|
-
* <blogmcp-
|
|
5
|
+
* <blogmcp-discussion> article-id, api-base, site-base, lang, sign-in-href, routes, poll-ms, nonce
|
|
6
|
+
* (+ the `realtime` property: a session factory the host passes, M2)
|
|
7
|
+
* <blogmcp-notice-form> api-base, site-base, lang, turnstile-site-key, target | article-id, routes, nonce
|
|
8
|
+
* <blogmcp-my-community> api-base, site-base, lang, decision-id, routes, sign-in-href, nonce
|
|
9
|
+
* <blogmcp-notification-centre> api-base, site-base, lang, routes, sign-in-href, nonce (M2)
|
|
10
|
+
* <blogmcp-badge> api-base, site-base, lang, routes, poll-ms, nonce (M2)
|
|
11
|
+
* <blogmcp-member-settings> api-base, site-base, lang, sw-path, routes, sign-in-href, nonce (M2)
|
|
12
|
+
* <blogmcp-profile> handle, api-base, site-base, lang, routes, sign-in-href, nonce (M2)
|
|
13
|
+
* <blogmcp-welcome> api-base, site-base, lang, routes, sign-in-href, nonce (M2)
|
|
8
14
|
*
|
|
9
15
|
* Each carries ONE inline stylesheet in its shadow root, with the CSP nonce honoured: the one given when the
|
|
10
16
|
* elements are defined (the script build passes `document.currentScript.nonce`), else the element's own
|
|
11
17
|
* `nonce` — read from the property, because a page with a CSP hides the attribute once it is connected.
|
|
12
18
|
*/
|
|
13
19
|
import { defineCustomElement, type Component } from 'vue'
|
|
20
|
+
import BadgeView from './ui/BadgeView.vue'
|
|
14
21
|
import DiscussionView from './ui/DiscussionView.vue'
|
|
22
|
+
import MemberSettingsView from './ui/MemberSettingsView.vue'
|
|
15
23
|
import MyCommunityView from './ui/MyCommunityView.vue'
|
|
16
24
|
import NoticeFormView from './ui/NoticeFormView.vue'
|
|
25
|
+
import NotificationCentreView from './ui/NotificationCentreView.vue'
|
|
26
|
+
import ProfileView from './ui/ProfileView.vue'
|
|
27
|
+
import WelcomeView from './ui/WelcomeView.vue'
|
|
17
28
|
import css from './ui/styles.css?inline'
|
|
18
29
|
|
|
19
30
|
/**
|
|
@@ -36,6 +47,12 @@ function ensureVueFeatureFlags(): void {
|
|
|
36
47
|
export const DISCUSSION_ELEMENT_TAG = 'blogmcp-discussion'
|
|
37
48
|
export const NOTICE_FORM_ELEMENT_TAG = 'blogmcp-notice-form'
|
|
38
49
|
export const MY_COMMUNITY_ELEMENT_TAG = 'blogmcp-my-community'
|
|
50
|
+
/** M2 (m2-contracts.md §3.10). */
|
|
51
|
+
export const NOTIFICATION_CENTRE_ELEMENT_TAG = 'blogmcp-notification-centre'
|
|
52
|
+
export const BADGE_ELEMENT_TAG = 'blogmcp-badge'
|
|
53
|
+
export const MEMBER_SETTINGS_ELEMENT_TAG = 'blogmcp-member-settings'
|
|
54
|
+
export const PROFILE_ELEMENT_TAG = 'blogmcp-profile'
|
|
55
|
+
export const WELCOME_ELEMENT_TAG = 'blogmcp-welcome'
|
|
39
56
|
|
|
40
57
|
export interface DefineCommunityElementOptions {
|
|
41
58
|
/** Another tag name for the element. */
|
|
@@ -47,7 +64,16 @@ export interface DefineCommunityElementOptions {
|
|
|
47
64
|
export interface DefineCommunityElementsOptions {
|
|
48
65
|
/** CSP nonce for the elements' `<style>`. */
|
|
49
66
|
nonce?: string
|
|
50
|
-
tags?: {
|
|
67
|
+
tags?: {
|
|
68
|
+
discussion?: string
|
|
69
|
+
noticeForm?: string
|
|
70
|
+
myCommunity?: string
|
|
71
|
+
notificationCentre?: string
|
|
72
|
+
badge?: string
|
|
73
|
+
memberSettings?: string
|
|
74
|
+
profile?: string
|
|
75
|
+
welcome?: string
|
|
76
|
+
}
|
|
51
77
|
}
|
|
52
78
|
|
|
53
79
|
/** The element's own nonce: the property (a CSP hides the attribute once connected), else the attribute. */
|
|
@@ -101,9 +127,39 @@ export function defineMyCommunityElement(opts: DefineCommunityElementOptions = {
|
|
|
101
127
|
define(opts.tag ?? MY_COMMUNITY_ELEMENT_TAG, MyCommunityView, opts.nonce)
|
|
102
128
|
}
|
|
103
129
|
|
|
104
|
-
/** Register
|
|
130
|
+
/** Register `<blogmcp-notification-centre>` (once). Browser only. */
|
|
131
|
+
export function defineNotificationCentreElement(opts: DefineCommunityElementOptions = {}): void {
|
|
132
|
+
define(opts.tag ?? NOTIFICATION_CENTRE_ELEMENT_TAG, NotificationCentreView, opts.nonce)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Register `<blogmcp-badge>` (once). Browser only. */
|
|
136
|
+
export function defineBadgeElement(opts: DefineCommunityElementOptions = {}): void {
|
|
137
|
+
define(opts.tag ?? BADGE_ELEMENT_TAG, BadgeView, opts.nonce)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Register `<blogmcp-member-settings>` (once). Browser only. */
|
|
141
|
+
export function defineMemberSettingsElement(opts: DefineCommunityElementOptions = {}): void {
|
|
142
|
+
define(opts.tag ?? MEMBER_SETTINGS_ELEMENT_TAG, MemberSettingsView, opts.nonce)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Register `<blogmcp-profile>` (once). Browser only. */
|
|
146
|
+
export function defineProfileElement(opts: DefineCommunityElementOptions = {}): void {
|
|
147
|
+
define(opts.tag ?? PROFILE_ELEMENT_TAG, ProfileView, opts.nonce)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Register `<blogmcp-welcome>` (once). Browser only. */
|
|
151
|
+
export function defineWelcomeElement(opts: DefineCommunityElementOptions = {}): void {
|
|
152
|
+
define(opts.tag ?? WELCOME_ELEMENT_TAG, WelcomeView, opts.nonce)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Register all eight (once). Browser only. */
|
|
105
156
|
export function defineCommunityElements(opts: DefineCommunityElementsOptions = {}): void {
|
|
106
157
|
defineDiscussionElement({ tag: opts.tags?.discussion, nonce: opts.nonce })
|
|
107
158
|
defineNoticeFormElement({ tag: opts.tags?.noticeForm, nonce: opts.nonce })
|
|
108
159
|
defineMyCommunityElement({ tag: opts.tags?.myCommunity, nonce: opts.nonce })
|
|
160
|
+
defineNotificationCentreElement({ tag: opts.tags?.notificationCentre, nonce: opts.nonce })
|
|
161
|
+
defineBadgeElement({ tag: opts.tags?.badge, nonce: opts.nonce })
|
|
162
|
+
defineMemberSettingsElement({ tag: opts.tags?.memberSettings, nonce: opts.nonce })
|
|
163
|
+
defineProfileElement({ tag: opts.tags?.profile, nonce: opts.nonce })
|
|
164
|
+
defineWelcomeElement({ tag: opts.tags?.welcome, nonce: opts.nonce })
|
|
109
165
|
}
|
package/src/global.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `<script>` build: registers `<blogmcp-discussion>`, `<blogmcp-notice-form
|
|
3
|
-
*
|
|
2
|
+
* The `<script>` build: registers every element — `<blogmcp-discussion>`, `<blogmcp-notice-form>`,
|
|
3
|
+
* `<blogmcp-my-community>` and (M2) `<blogmcp-notification-centre>`, `<blogmcp-badge>`, `<blogmcp-member-settings>`,
|
|
4
|
+
* `<blogmcp-profile>`, `<blogmcp-welcome>` — with the script tag's own CSP nonce, and exposes
|
|
5
|
+
* `window.BlogmcpCommunity`. Live updates are not in it: a site that wants them bundles `@blogmcp/sdk/community/realtime`
|
|
6
|
+
* and sets the discussion's `realtime` property.
|
|
4
7
|
*/
|
|
5
8
|
import { defineCommunityElements } from './element'
|
|
6
|
-
import { mountDiscussion, mountMyCommunity, mountNoticeForm } from './mount'
|
|
9
|
+
import { mountBadge, mountDiscussion, mountMemberSettings, mountMyCommunity, mountNoticeForm, mountNotificationCentre, mountProfile, mountWelcome } from './mount'
|
|
7
10
|
import { VERSION } from './version'
|
|
8
11
|
|
|
9
12
|
const script = typeof document !== 'undefined' ? (document.currentScript as HTMLScriptElement | null) : null
|
|
10
13
|
defineCommunityElements({ nonce: script?.nonce || undefined })
|
|
11
14
|
|
|
12
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
defineCommunityElements,
|
|
17
|
+
mountDiscussion,
|
|
18
|
+
mountMyCommunity,
|
|
19
|
+
mountNoticeForm,
|
|
20
|
+
mountNotificationCentre,
|
|
21
|
+
mountBadge,
|
|
22
|
+
mountMemberSettings,
|
|
23
|
+
mountProfile,
|
|
24
|
+
mountWelcome,
|
|
25
|
+
VERSION,
|
|
26
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* - `<blogmcp-discussion>` the discussion under an article;
|
|
8
8
|
* - `<blogmcp-notice-form>` the DSA notice form, for anyone;
|
|
9
9
|
* - `<blogmcp-my-community>` the reader's „Moje komunita", a statement page, a notifier's status page;
|
|
10
|
+
* - M2: `<blogmcp-notification-centre>` the notifications, `<blogmcp-badge>` the header's unread count,
|
|
11
|
+
* `<blogmcp-member-settings>` the switches, push and the digest's unsubscribe, `<blogmcp-profile>` a member's
|
|
12
|
+
* public profile, `<blogmcp-welcome>` the welcome page; the discussion's `realtime` property (live updates);
|
|
10
13
|
* - `defineCommunityElements()` and one `define…Element()` each — the custom elements;
|
|
11
14
|
* - `mountDiscussion(…)`, `mountNoticeForm(…)`, `mountMyCommunity(…)` — mount one from code;
|
|
12
15
|
* - `@blogmcp/community/vue`, `@blogmcp/community/react` — `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`;
|
|
@@ -17,13 +20,45 @@ export {
|
|
|
17
20
|
defineDiscussionElement,
|
|
18
21
|
defineNoticeFormElement,
|
|
19
22
|
defineMyCommunityElement,
|
|
23
|
+
defineNotificationCentreElement,
|
|
24
|
+
defineBadgeElement,
|
|
25
|
+
defineMemberSettingsElement,
|
|
26
|
+
defineProfileElement,
|
|
27
|
+
defineWelcomeElement,
|
|
20
28
|
DISCUSSION_ELEMENT_TAG,
|
|
21
29
|
NOTICE_FORM_ELEMENT_TAG,
|
|
22
30
|
MY_COMMUNITY_ELEMENT_TAG,
|
|
31
|
+
NOTIFICATION_CENTRE_ELEMENT_TAG,
|
|
32
|
+
BADGE_ELEMENT_TAG,
|
|
33
|
+
MEMBER_SETTINGS_ELEMENT_TAG,
|
|
34
|
+
PROFILE_ELEMENT_TAG,
|
|
35
|
+
WELCOME_ELEMENT_TAG,
|
|
23
36
|
type DefineCommunityElementOptions,
|
|
24
37
|
type DefineCommunityElementsOptions,
|
|
25
38
|
} from './element'
|
|
26
|
-
export {
|
|
39
|
+
export {
|
|
40
|
+
mountDiscussion,
|
|
41
|
+
mountNoticeForm,
|
|
42
|
+
mountMyCommunity,
|
|
43
|
+
mountNotificationCentre,
|
|
44
|
+
mountBadge,
|
|
45
|
+
mountMemberSettings,
|
|
46
|
+
mountProfile,
|
|
47
|
+
mountWelcome,
|
|
48
|
+
type MountDiscussionOptions,
|
|
49
|
+
type MountNoticeFormOptions,
|
|
50
|
+
type MountMyCommunityOptions,
|
|
51
|
+
type MountNotificationCentreOptions,
|
|
52
|
+
type MountBadgeOptions,
|
|
53
|
+
type MountMemberSettingsOptions,
|
|
54
|
+
type MountProfileOptions,
|
|
55
|
+
type MountWelcomeOptions,
|
|
56
|
+
} from './mount'
|
|
57
|
+
export { createNotificationCentre, type NotificationCentreController, type CentreUi } from './core/centre'
|
|
58
|
+
export { createBadge, badgeText, BADGE_INTERVAL_MS, type BadgeController, type BadgeOptions, type BadgeUi } from './core/badge'
|
|
59
|
+
export { createMemberSettings, SETTING_KEYS, type MemberSettingsController, type MemberSettingsOptions, type SettingsUi, type SettingKey } from './core/settings'
|
|
60
|
+
export { createProfile, createWelcome, type ProfileController, type WelcomeController } from './core/profile'
|
|
61
|
+
export type { DiscussionRealtime, DiscussionRealtimeOptions, DiscussionRealtimeSession, DiscussionRealtimeMode } from './core/realtime'
|
|
27
62
|
export { createDiscussionController, type DiscussionController, type DiscussionControllerOptions, type DiscussionUi, type ComposerMode, type Draft } from './core/discussion'
|
|
28
63
|
export { createNoticeForm, type NoticeFormController, type NoticeFormOptions, type NoticeFormUi } from './core/notice'
|
|
29
64
|
export { createPanel, type PanelController, type PanelOptions, type PanelUi } from './core/panel'
|
package/src/mount.ts
CHANGED
|
@@ -7,13 +7,24 @@
|
|
|
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
11
|
import {
|
|
12
|
+
BADGE_ELEMENT_TAG,
|
|
11
13
|
DISCUSSION_ELEMENT_TAG,
|
|
14
|
+
MEMBER_SETTINGS_ELEMENT_TAG,
|
|
12
15
|
MY_COMMUNITY_ELEMENT_TAG,
|
|
13
16
|
NOTICE_FORM_ELEMENT_TAG,
|
|
17
|
+
NOTIFICATION_CENTRE_ELEMENT_TAG,
|
|
18
|
+
PROFILE_ELEMENT_TAG,
|
|
19
|
+
WELCOME_ELEMENT_TAG,
|
|
20
|
+
defineBadgeElement,
|
|
14
21
|
defineDiscussionElement,
|
|
22
|
+
defineMemberSettingsElement,
|
|
15
23
|
defineMyCommunityElement,
|
|
16
24
|
defineNoticeFormElement,
|
|
25
|
+
defineNotificationCentreElement,
|
|
26
|
+
defineProfileElement,
|
|
27
|
+
defineWelcomeElement,
|
|
17
28
|
} from './element'
|
|
18
29
|
|
|
19
30
|
interface CommonMountOptions {
|
|
@@ -42,6 +53,11 @@ export interface MountDiscussionOptions extends CommonMountOptions {
|
|
|
42
53
|
signInHref?: string
|
|
43
54
|
/** Head poll interval in ms (default 20 000; 0 = off). */
|
|
44
55
|
pollMs?: number
|
|
56
|
+
/**
|
|
57
|
+
* M2: live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime` (the host imports it; the element
|
|
58
|
+
* never does). Absent: the head poll.
|
|
59
|
+
*/
|
|
60
|
+
realtime?: DiscussionRealtime
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export interface MountNoticeFormOptions extends CommonMountOptions {
|
|
@@ -60,7 +76,36 @@ export interface MountMyCommunityOptions extends CommonMountOptions {
|
|
|
60
76
|
signInHref?: string
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
|
|
79
|
+
export interface MountNotificationCentreOptions extends CommonMountOptions {
|
|
80
|
+
/** The site's sign-in page. */
|
|
81
|
+
signInHref?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface MountBadgeOptions extends CommonMountOptions {
|
|
85
|
+
/** The unread count's poll in ms (default 60 000; 0 = once, and when the page is shown again). */
|
|
86
|
+
pollMs?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface MountMemberSettingsOptions extends CommonMountOptions {
|
|
90
|
+
/** The host's service worker route (default `<site-base>/sw.js`, served from `@blogmcp/sdk/community/sw`). */
|
|
91
|
+
swPath?: string
|
|
92
|
+
/** The site's sign-in page. */
|
|
93
|
+
signInHref?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MountProfileOptions extends CommonMountOptions {
|
|
97
|
+
/** The member's handle. */
|
|
98
|
+
handle: string
|
|
99
|
+
/** The site's sign-in page. */
|
|
100
|
+
signInHref?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MountWelcomeOptions extends CommonMountOptions {
|
|
104
|
+
/** The site's sign-in page (a visitor's link in the profile's place). */
|
|
105
|
+
signInHref?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type Settable = HTMLElement & { transport?: CommunityTransport; realtime?: DiscussionRealtime }
|
|
64
109
|
|
|
65
110
|
function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
|
|
66
111
|
for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
|
|
@@ -88,6 +133,7 @@ export function mountDiscussion(o: MountDiscussionOptions): () => void {
|
|
|
88
133
|
'poll-ms': o.pollMs,
|
|
89
134
|
nonce: o.nonce,
|
|
90
135
|
})
|
|
136
|
+
if (o.realtime) el.realtime = o.realtime
|
|
91
137
|
return place(el, o)
|
|
92
138
|
}
|
|
93
139
|
|
|
@@ -125,3 +171,64 @@ export function mountMyCommunity(o: MountMyCommunityOptions = {}): () => void {
|
|
|
125
171
|
})
|
|
126
172
|
return place(el, o)
|
|
127
173
|
}
|
|
174
|
+
|
|
175
|
+
/** Mount `<blogmcp-notification-centre>`; returns the function that removes it. */
|
|
176
|
+
export function mountNotificationCentre(o: MountNotificationCentreOptions = {}): () => void {
|
|
177
|
+
if (typeof document === 'undefined') return () => {}
|
|
178
|
+
defineNotificationCentreElement({ nonce: o.nonce })
|
|
179
|
+
const el = document.createElement(NOTIFICATION_CENTRE_ELEMENT_TAG) as Settable
|
|
180
|
+
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 })
|
|
181
|
+
return place(el, o)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Mount `<blogmcp-badge>`; returns the function that removes it. */
|
|
185
|
+
export function mountBadge(o: MountBadgeOptions = {}): () => void {
|
|
186
|
+
if (typeof document === 'undefined') return () => {}
|
|
187
|
+
defineBadgeElement({ nonce: o.nonce })
|
|
188
|
+
const el = document.createElement(BADGE_ELEMENT_TAG) as Settable
|
|
189
|
+
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 })
|
|
190
|
+
return place(el, o)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Mount `<blogmcp-member-settings>`; returns the function that removes it. */
|
|
194
|
+
export function mountMemberSettings(o: MountMemberSettingsOptions = {}): () => void {
|
|
195
|
+
if (typeof document === 'undefined') return () => {}
|
|
196
|
+
defineMemberSettingsElement({ nonce: o.nonce })
|
|
197
|
+
const el = document.createElement(MEMBER_SETTINGS_ELEMENT_TAG) as Settable
|
|
198
|
+
attrs(el, {
|
|
199
|
+
'api-base': o.apiBase,
|
|
200
|
+
'site-base': o.siteBase,
|
|
201
|
+
lang: o.lang,
|
|
202
|
+
'sw-path': o.swPath,
|
|
203
|
+
routes: o.routes ? JSON.stringify(o.routes) : undefined,
|
|
204
|
+
'sign-in-href': o.signInHref,
|
|
205
|
+
nonce: o.nonce,
|
|
206
|
+
})
|
|
207
|
+
return place(el, o)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Mount `<blogmcp-profile>`; returns the function that removes it. */
|
|
211
|
+
export function mountProfile(o: MountProfileOptions): () => void {
|
|
212
|
+
if (typeof document === 'undefined') return () => {}
|
|
213
|
+
defineProfileElement({ nonce: o.nonce })
|
|
214
|
+
const el = document.createElement(PROFILE_ELEMENT_TAG) as Settable
|
|
215
|
+
attrs(el, {
|
|
216
|
+
handle: o.handle,
|
|
217
|
+
'api-base': o.apiBase,
|
|
218
|
+
'site-base': o.siteBase,
|
|
219
|
+
lang: o.lang,
|
|
220
|
+
routes: o.routes ? JSON.stringify(o.routes) : undefined,
|
|
221
|
+
'sign-in-href': o.signInHref,
|
|
222
|
+
nonce: o.nonce,
|
|
223
|
+
})
|
|
224
|
+
return place(el, o)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Mount `<blogmcp-welcome>`; returns the function that removes it. */
|
|
228
|
+
export function mountWelcome(o: MountWelcomeOptions = {}): () => void {
|
|
229
|
+
if (typeof document === 'undefined') return () => {}
|
|
230
|
+
defineWelcomeElement({ nonce: o.nonce })
|
|
231
|
+
const el = document.createElement(WELCOME_ELEMENT_TAG) as Settable
|
|
232
|
+
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
|
+
return place(el, o)
|
|
234
|
+
}
|
package/src/react.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`
|
|
2
|
+
* `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` and (M2) `<KbNotificationCentre>`, `<KbBadge>`,
|
|
3
|
+
* `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` for React (18 and 19, Next.js App Router included — this
|
|
3
4
|
* module is a client component): each mounts its element after hydration and removes it on unmount. Each
|
|
4
5
|
* renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
|
|
5
6
|
* sits where the component does and the page's markup is otherwise the site's alone.
|
|
@@ -7,11 +8,25 @@
|
|
|
7
8
|
* <KbDiscussion articleId={article.id} apiBase="/api/community" signInHref={`/sign-in#${pathname}`} />
|
|
8
9
|
*/
|
|
9
10
|
import { createElement, useEffect, useRef, type ReactElement } from 'react'
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
MountBadgeOptions,
|
|
13
|
+
MountDiscussionOptions,
|
|
14
|
+
MountMemberSettingsOptions,
|
|
15
|
+
MountMyCommunityOptions,
|
|
16
|
+
MountNoticeFormOptions,
|
|
17
|
+
MountNotificationCentreOptions,
|
|
18
|
+
MountProfileOptions,
|
|
19
|
+
MountWelcomeOptions,
|
|
20
|
+
} from './mount'
|
|
11
21
|
|
|
12
22
|
export type KbDiscussionProps = Omit<MountDiscussionOptions, 'container'>
|
|
13
23
|
export type KbNoticeFormProps = Omit<MountNoticeFormOptions, 'container'>
|
|
14
24
|
export type KbMyCommunityProps = Omit<MountMyCommunityOptions, 'container'>
|
|
25
|
+
export type KbNotificationCentreProps = Omit<MountNotificationCentreOptions, 'container'>
|
|
26
|
+
export type KbBadgeProps = Omit<MountBadgeOptions, 'container'>
|
|
27
|
+
export type KbMemberSettingsProps = Omit<MountMemberSettingsOptions, 'container'>
|
|
28
|
+
export type KbProfileProps = Omit<MountProfileOptions, 'container'>
|
|
29
|
+
export type KbWelcomeProps = Omit<MountWelcomeOptions, 'container'>
|
|
15
30
|
|
|
16
31
|
/** The options that, changed, mount the element anew (functions by identity are left out). */
|
|
17
32
|
const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
|
|
@@ -50,4 +65,33 @@ export function KbMyCommunity(props: KbMyCommunityProps): ReactElement {
|
|
|
50
65
|
return useMounted('my-community', props, async () => (await import('./mount')).mountMyCommunity)
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
export
|
|
68
|
+
export function KbNotificationCentre(props: KbNotificationCentreProps): ReactElement {
|
|
69
|
+
return useMounted('notification-centre', props, async () => (await import('./mount')).mountNotificationCentre)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function KbBadge(props: KbBadgeProps): ReactElement {
|
|
73
|
+
return useMounted('badge', props, async () => (await import('./mount')).mountBadge)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function KbMemberSettings(props: KbMemberSettingsProps): ReactElement {
|
|
77
|
+
return useMounted('member-settings', props, async () => (await import('./mount')).mountMemberSettings)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function KbProfile(props: KbProfileProps): ReactElement {
|
|
81
|
+
return useMounted('profile', props, async () => (await import('./mount')).mountProfile)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function KbWelcome(props: KbWelcomeProps): ReactElement {
|
|
85
|
+
return useMounted('welcome', props, async () => (await import('./mount')).mountWelcome)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type {
|
|
89
|
+
MountDiscussionOptions,
|
|
90
|
+
MountNoticeFormOptions,
|
|
91
|
+
MountMyCommunityOptions,
|
|
92
|
+
MountNotificationCentreOptions,
|
|
93
|
+
MountBadgeOptions,
|
|
94
|
+
MountMemberSettingsOptions,
|
|
95
|
+
MountProfileOptions,
|
|
96
|
+
MountWelcomeOptions,
|
|
97
|
+
}
|
|
@@ -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>
|
package/src/ui/LinkedText.vue
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* A member's text, as text: runs of text nodes,
|
|
4
|
-
*
|
|
3
|
+
* A member's text, as text: runs of text nodes, the web addresses in it as links that went through sanitizeUrl
|
|
4
|
+
* (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab — and, with `profilePath` (M2), each `@handle`
|
|
5
|
+
* as a link to that member's profile page on this site. Never HTML.
|
|
5
6
|
*/
|
|
6
7
|
import { computed } from 'vue'
|
|
7
|
-
import {
|
|
8
|
+
import { profilePath as profilePathOf } from '@blogmcp/sdk/community'
|
|
9
|
+
import { linkifyWithMentions, UGC_LINK_REL } from '../core/text'
|
|
8
10
|
|
|
9
|
-
const props = defineProps<{ text: string | null }>()
|
|
10
|
-
const segments = computed(() =>
|
|
11
|
+
const props = defineProps<{ text: string | null; profilePath?: string | null }>()
|
|
12
|
+
const segments = computed(() => {
|
|
13
|
+
const template = props.profilePath
|
|
14
|
+
return linkifyWithMentions(props.text ?? '', template ? (handle: string) => profilePathOf(template, handle) : null)
|
|
15
|
+
})
|
|
11
16
|
</script>
|
|
12
17
|
|
|
13
18
|
<template>
|
|
14
19
|
<template v-for="(s, i) in segments" :key="i">
|
|
15
20
|
<a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
|
|
21
|
+
<a v-else-if="s.kind === 'mention'" class="bcm-mention" :href="s.href">{{ s.text }}</a>
|
|
16
22
|
<template v-else>{{ s.text }}</template>
|
|
17
23
|
</template>
|
|
18
24
|
</template>
|