@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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. 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
+ }