@blogmcp/community 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- 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/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +242 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-notification-centre>`'s brain (m2-contracts.md §3.3, §3.10): the member's notifications newest first —
|
|
3
|
+
* `me/notifications/list` with its cursor and the unread-only filter —, one marked read at a time or all at once
|
|
4
|
+
* (`mark-read { all: true }`). Codes only, never anything a member wrote: each row is the catalogue's sentence of
|
|
5
|
+
* its code, and its subject a link the host's routes build (a decision → its statement).
|
|
6
|
+
*/
|
|
7
|
+
import { reactive } from 'vue'
|
|
8
|
+
import {
|
|
9
|
+
isKnownNotificationCode,
|
|
10
|
+
type CommunityCallOptions,
|
|
11
|
+
type CommunityClient,
|
|
12
|
+
type CommunityFailure,
|
|
13
|
+
type NotificationItem,
|
|
14
|
+
} from '@blogmcp/sdk/community'
|
|
15
|
+
import type { TextRef } from './i18n'
|
|
16
|
+
|
|
17
|
+
export type CentrePhase = 'idle' | 'loading' | 'ready' | 'failed' | 'signIn'
|
|
18
|
+
|
|
19
|
+
export interface CentreUi {
|
|
20
|
+
phase: CentrePhase
|
|
21
|
+
failure: CommunityFailure | null
|
|
22
|
+
/** Known codes only (a code a newer kb-api sends is skipped, never shown raw). */
|
|
23
|
+
items: NotificationItem[]
|
|
24
|
+
nextCursor: string | null
|
|
25
|
+
unreadOnly: boolean
|
|
26
|
+
loadingMore: boolean
|
|
27
|
+
busy: Record<string, boolean>
|
|
28
|
+
announcement: TextRef | null
|
|
29
|
+
announceId: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NotificationCentreController {
|
|
33
|
+
readonly state: CentreUi
|
|
34
|
+
load(): Promise<void>
|
|
35
|
+
loadMore(): Promise<void>
|
|
36
|
+
setUnreadOnly(on: boolean): Promise<void>
|
|
37
|
+
markRead(id: string): Promise<void>
|
|
38
|
+
markAllRead(): Promise<void>
|
|
39
|
+
unread(): number
|
|
40
|
+
dispose(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createNotificationCentre(o: { client: CommunityClient }): NotificationCentreController {
|
|
44
|
+
const { client } = o
|
|
45
|
+
const abort = new AbortController()
|
|
46
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
47
|
+
let disposed = false
|
|
48
|
+
/** The list that is current: an older one still on its way (the filter changed meanwhile) is dropped. */
|
|
49
|
+
let ticket = 0
|
|
50
|
+
const state = reactive<CentreUi>({
|
|
51
|
+
phase: 'idle',
|
|
52
|
+
failure: null,
|
|
53
|
+
items: [],
|
|
54
|
+
nextCursor: null,
|
|
55
|
+
unreadOnly: false,
|
|
56
|
+
loadingMore: false,
|
|
57
|
+
busy: {},
|
|
58
|
+
announcement: null,
|
|
59
|
+
announceId: 0,
|
|
60
|
+
}) as CentreUi
|
|
61
|
+
|
|
62
|
+
const announce = (text: TextRef): void => {
|
|
63
|
+
state.announcement = text
|
|
64
|
+
state.announceId += 1
|
|
65
|
+
}
|
|
66
|
+
const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
|
|
67
|
+
const known = (items: readonly NotificationItem[]): NotificationItem[] => items.filter((n) => isKnownNotificationCode(n.code))
|
|
68
|
+
|
|
69
|
+
async function load(): Promise<void> {
|
|
70
|
+
const mine = ++ticket
|
|
71
|
+
state.phase = 'loading'
|
|
72
|
+
state.failure = null
|
|
73
|
+
const r = await client.me.notifications.list(state.unreadOnly ? { unreadOnly: true } : {}, call)
|
|
74
|
+
if (disposed || mine !== ticket) return
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
state.phase = r.code === 'signIn' ? 'signIn' : 'failed'
|
|
77
|
+
state.failure = r
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
state.items = known(r.value.items)
|
|
81
|
+
state.nextCursor = r.value.nextCursor
|
|
82
|
+
state.phase = 'ready'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
state,
|
|
87
|
+
load,
|
|
88
|
+
async loadMore() {
|
|
89
|
+
if (state.loadingMore || state.nextCursor === null || state.phase !== 'ready') return
|
|
90
|
+
const mine = ticket
|
|
91
|
+
state.loadingMore = true
|
|
92
|
+
const r = await client.me.notifications.list({ cursor: state.nextCursor, ...(state.unreadOnly ? { unreadOnly: true } : {}) }, call)
|
|
93
|
+
state.loadingMore = false
|
|
94
|
+
if (disposed || mine !== ticket) return
|
|
95
|
+
if (!r.ok) return announce(failureRef(r))
|
|
96
|
+
const seen = new Set(state.items.map((n) => n.id))
|
|
97
|
+
state.items = [...state.items, ...known(r.value.items).filter((n) => !seen.has(n.id))]
|
|
98
|
+
state.nextCursor = r.value.nextCursor
|
|
99
|
+
},
|
|
100
|
+
async setUnreadOnly(on) {
|
|
101
|
+
if (state.unreadOnly === on) return
|
|
102
|
+
state.unreadOnly = on
|
|
103
|
+
await load()
|
|
104
|
+
},
|
|
105
|
+
async markRead(id) {
|
|
106
|
+
const key = `read:${id}`
|
|
107
|
+
const item = state.items.find((n) => n.id === id)
|
|
108
|
+
if (!item || item.readAt !== null || state.busy[key]) return
|
|
109
|
+
state.busy[key] = true
|
|
110
|
+
const r = await client.me.notifications.markRead({ ids: [id] }, call)
|
|
111
|
+
delete state.busy[key]
|
|
112
|
+
if (disposed) return
|
|
113
|
+
if (!r.ok) return announce(failureRef(r))
|
|
114
|
+
const at = new Date().toISOString()
|
|
115
|
+
state.items = state.items.map((n) => (n.id === id ? { ...n, readAt: at } : n))
|
|
116
|
+
},
|
|
117
|
+
async markAllRead() {
|
|
118
|
+
// Nothing unread among the loaded rows and no older page: nothing to mark (older pages may hold unread rows).
|
|
119
|
+
if (state.busy.all || (!state.items.some((n) => n.readAt === null) && state.nextCursor === null)) return
|
|
120
|
+
state.busy.all = true
|
|
121
|
+
const r = await client.me.notifications.markRead({ all: true }, call)
|
|
122
|
+
delete state.busy.all
|
|
123
|
+
if (disposed) return
|
|
124
|
+
if (!r.ok) return announce(failureRef(r))
|
|
125
|
+
const at = new Date().toISOString()
|
|
126
|
+
// Under the unread-only filter nothing is left to show; otherwise every row reads as read.
|
|
127
|
+
state.items = state.unreadOnly ? [] : state.items.map((n) => (n.readAt === null ? { ...n, readAt: at } : n))
|
|
128
|
+
if (state.unreadOnly) state.nextCursor = null
|
|
129
|
+
},
|
|
130
|
+
unread: () => state.items.filter((n) => n.readAt === null).length,
|
|
131
|
+
dispose() {
|
|
132
|
+
disposed = true
|
|
133
|
+
abort.abort()
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client an element talks through: the SDK's `createCommunityClient` over the HOST's relative base
|
|
3
|
+
* (`api-base`; default `/api/community` under the site's `site-base` — a headless site's BFF, the storefront's,
|
|
4
|
+
* or with `site-base="/blog"` a managed blog's own proxy). Never kb-api directly: its community namespaces answer
|
|
5
|
+
* no CORS (m1-contracts.md §7.1), and the client refuses an absolute base. On a managed blog the tenant host's
|
|
6
|
+
* `/api/` IS kb-api, so `/api/community` there would skip the blog's proxy and its session. An element with an
|
|
7
|
+
* unusable base warns once and renders nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { createCommunityClient, isRelativeCommunityBase, type CommunityClient, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
10
|
+
import { siteBaseOf } from './attrs'
|
|
11
|
+
|
|
12
|
+
/** The base the hosts serve the community under, below their site base (§12; the BFF's default basePath). */
|
|
13
|
+
export const DEFAULT_API_BASE = '/api/community'
|
|
14
|
+
|
|
15
|
+
const warned = new Set<string>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One console line per problem and page, for the site's developer — never a value from the page. The
|
|
19
|
+
* message starts with `[blogmcp-community]` (test/text-only.spec.ts tells developer lines from reader text
|
|
20
|
+
* by it).
|
|
21
|
+
*/
|
|
22
|
+
export function warnOnce(key: string, message: `[blogmcp-community] ${string}`): void {
|
|
23
|
+
if (warned.has(key)) return
|
|
24
|
+
warned.add(key)
|
|
25
|
+
console.warn(message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `site-base` attribute as a path prefix ('' at the site's root), or null — the developer told once — when it
|
|
30
|
+
* is no path on this site.
|
|
31
|
+
*/
|
|
32
|
+
export function siteBaseFor(value: string | null | undefined): string | null {
|
|
33
|
+
const base = siteBaseOf(value)
|
|
34
|
+
if (base === null) warnOnce('site-base', '[blogmcp-community] site-base must be a path on this site (e.g. "/blog"), never a URL: the site’s pages and its proxy are found under it')
|
|
35
|
+
return base
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The client for an element's `api-base` (and an injected transport), or null when the base is not a relative
|
|
40
|
+
* path. Without an `api-base`: `/api/community` under the site's base.
|
|
41
|
+
*/
|
|
42
|
+
export function communityClientFor(apiBase: string | null | undefined, transport?: CommunityTransport | null, siteBase = ''): CommunityClient | null {
|
|
43
|
+
const base = apiBase === undefined || apiBase === null || apiBase === '' ? `${siteBase}${DEFAULT_API_BASE}` : apiBase
|
|
44
|
+
if (!isRelativeCommunityBase(base)) {
|
|
45
|
+
warnOnce('api-base', '[blogmcp-community] api-base must be a path on this site (e.g. "/api/community"), never a URL: the community API is reached through the site’s own proxy or BFF')
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return createCommunityClient(transport ? { base, transport } : { base })
|
|
50
|
+
} catch {
|
|
51
|
+
warnOnce('api-base', '[blogmcp-community] api-base was refused by the community client')
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The digest's unsubscribe token as `<blogmcp-member-settings>` takes it (m2-contracts.md §3.3, §9.4): from its OWN
|
|
3
|
+
* page's address only — `#u=` in `location.hash` when it mounts —, and the address bar cleared at once (the SDK's
|
|
4
|
+
* readDigestTokenFromHash, which also clears the router's copy in `history.state`). The token is never sent on load:
|
|
5
|
+
* the element POSTs it on the member's click only (a mail scanner that opens the link must not unsubscribe anyone).
|
|
6
|
+
*
|
|
7
|
+
* As with a notifier's receipt (receipt.ts), the element keeps what it took for itself; a later page of the same tab
|
|
8
|
+
* never inherits it.
|
|
9
|
+
*/
|
|
10
|
+
import { digestTokenFromHash, forgetDigestToken, readDigestTokenFromHash } from '@blogmcp/sdk/community'
|
|
11
|
+
|
|
12
|
+
const taken = new WeakMap<object, string | null>()
|
|
13
|
+
|
|
14
|
+
/** The token this element's page carries, or null (none, or a cut link). */
|
|
15
|
+
export function digestTokenForElement(owner: object | null): string | null {
|
|
16
|
+
if (typeof location !== 'undefined' && location.hash.startsWith('#u=')) {
|
|
17
|
+
// This address's own token (a cut link is none, never an older one the window remembers); then the bar is cleared.
|
|
18
|
+
const token = digestTokenFromHash(location.hash)
|
|
19
|
+
readDigestTokenFromHash()
|
|
20
|
+
// The element keeps its own copy (below); the SDK's per-window memory is dropped at once, so no later mount offers it.
|
|
21
|
+
forgetDigestToken()
|
|
22
|
+
if (owner) taken.set(owner, token)
|
|
23
|
+
return token
|
|
24
|
+
}
|
|
25
|
+
return owner && taken.has(owner) ? (taken.get(owner) ?? null) : null
|
|
26
|
+
}
|