@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
package/src/mount.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Put an element on the page from code: what the Vue and React wrappers do, and what any other framework can
3
+ * call from its client-side mount hook. The element is defined first, so it is created already upgraded (no
4
+ * upgrade of an unknown element later, which some DOMs never run), and every attribute and property is set
5
+ * BEFORE it connects, so it mounts with them; the returned function removes the element again (its poll and
6
+ * requests stop with it).
7
+ */
8
+ import type { CommunityTransport } from '@blogmcp/sdk/community'
9
+ import type { CommunityRoutes } from './core/attrs'
10
+ import type { DiscussionRealtime } from './core/realtime'
11
+ import {
12
+ BADGE_ELEMENT_TAG,
13
+ DISCUSSION_ELEMENT_TAG,
14
+ MEMBER_SETTINGS_ELEMENT_TAG,
15
+ MY_COMMUNITY_ELEMENT_TAG,
16
+ NOTICE_FORM_ELEMENT_TAG,
17
+ NOTIFICATION_CENTRE_ELEMENT_TAG,
18
+ PROFILE_ELEMENT_TAG,
19
+ WELCOME_ELEMENT_TAG,
20
+ defineBadgeElement,
21
+ defineDiscussionElement,
22
+ defineMemberSettingsElement,
23
+ defineMyCommunityElement,
24
+ defineNoticeFormElement,
25
+ defineNotificationCentreElement,
26
+ defineProfileElement,
27
+ defineWelcomeElement,
28
+ } from './element'
29
+
30
+ interface CommonMountOptions {
31
+ /** The host's relative base (default `/api/community` under `siteBase`): its proxy or BFF. Never kb-api's URL. */
32
+ apiBase?: string
33
+ /**
34
+ * The path the site is served under (`/blog` on a managed blog, whose host's own `/api/` is kb-api): the default
35
+ * api-base and the default pages are taken under it. A page or base the host names itself is used as it is.
36
+ */
37
+ siteBase?: string
38
+ /** `cs` or `en` (`sk` reads Czech); default the page's `<html lang>`. */
39
+ lang?: string
40
+ /** The host's community pages (statements, the panel, the rules, the notice form, a notifier's status). */
41
+ routes?: CommunityRoutes
42
+ /** CSP nonce for the element's stylesheet (and the Turnstile script). */
43
+ nonce?: string
44
+ /** A replacement transport (tests; a signing transport later). */
45
+ transport?: CommunityTransport
46
+ /** Where the element goes; default `document.body`. */
47
+ container?: Element
48
+ }
49
+
50
+ export interface MountDiscussionOptions extends CommonMountOptions {
51
+ articleId: string
52
+ /** The site's sign-in page; the current path is put in its fragment. */
53
+ signInHref?: string
54
+ /** Head poll interval in ms (default 20 000; 0 = off). */
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
61
+ }
62
+
63
+ export interface MountNoticeFormOptions extends CommonMountOptions {
64
+ /** What is reported: a message, a member (by id or handle) or an article. */
65
+ target?: { conversationId: string; seq: number } | { memberId: string } | { handle: string } | { articleId: string }
66
+ /** Or the article (when `target` is absent). */
67
+ articleId?: string
68
+ /** The site's Turnstile site key; without it no widget renders (kb-api then decides alone). */
69
+ turnstileSiteKey?: string
70
+ }
71
+
72
+ export interface MountMyCommunityOptions extends CommonMountOptions {
73
+ /** Open this statement of reasons (the host's statement page). */
74
+ decisionId?: string
75
+ /** The site's sign-in page. */
76
+ signInHref?: string
77
+ }
78
+
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 }
109
+
110
+ function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
111
+ for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
112
+ }
113
+
114
+ function place(el: Settable, o: CommonMountOptions): () => void {
115
+ // Set before the element connects, so it mounts with it.
116
+ if (o.transport) el.transport = o.transport
117
+ ;(o.container ?? document.body).appendChild(el)
118
+ return () => el.remove()
119
+ }
120
+
121
+ /** Mount `<blogmcp-discussion>`; returns the function that removes it. */
122
+ export function mountDiscussion(o: MountDiscussionOptions): () => void {
123
+ if (typeof document === 'undefined') return () => {}
124
+ defineDiscussionElement({ nonce: o.nonce })
125
+ const el = document.createElement(DISCUSSION_ELEMENT_TAG) as Settable
126
+ attrs(el, {
127
+ 'article-id': o.articleId,
128
+ 'api-base': o.apiBase,
129
+ 'site-base': o.siteBase,
130
+ lang: o.lang,
131
+ 'sign-in-href': o.signInHref,
132
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
133
+ 'poll-ms': o.pollMs,
134
+ nonce: o.nonce,
135
+ })
136
+ if (o.realtime) el.realtime = o.realtime
137
+ return place(el, o)
138
+ }
139
+
140
+ /** Mount `<blogmcp-notice-form>`; returns the function that removes it. */
141
+ export function mountNoticeForm(o: MountNoticeFormOptions): () => void {
142
+ if (typeof document === 'undefined') return () => {}
143
+ defineNoticeFormElement({ nonce: o.nonce })
144
+ const el = document.createElement(NOTICE_FORM_ELEMENT_TAG) as Settable
145
+ attrs(el, {
146
+ 'api-base': o.apiBase,
147
+ 'site-base': o.siteBase,
148
+ lang: o.lang,
149
+ 'turnstile-site-key': o.turnstileSiteKey,
150
+ target: o.target ? JSON.stringify(o.target) : undefined,
151
+ 'article-id': o.articleId,
152
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
153
+ nonce: o.nonce,
154
+ })
155
+ return place(el, o)
156
+ }
157
+
158
+ /** Mount `<blogmcp-my-community>`; returns the function that removes it. */
159
+ export function mountMyCommunity(o: MountMyCommunityOptions = {}): () => void {
160
+ if (typeof document === 'undefined') return () => {}
161
+ defineMyCommunityElement({ nonce: o.nonce })
162
+ const el = document.createElement(MY_COMMUNITY_ELEMENT_TAG) as Settable
163
+ attrs(el, {
164
+ 'api-base': o.apiBase,
165
+ 'site-base': o.siteBase,
166
+ lang: o.lang,
167
+ 'decision-id': o.decisionId,
168
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
169
+ 'sign-in-href': o.signInHref,
170
+ nonce: o.nonce,
171
+ })
172
+ return place(el, o)
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 ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` and (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
+ * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` for React (18 and 19, Next.js App Router included — this
4
+ * module is a client component): each mounts its element after hydration and removes it on unmount. Each
5
+ * renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
6
+ * sits where the component does and the page's markup is otherwise the site's alone.
7
+ *
8
+ * <KbDiscussion articleId={article.id} apiBase="/api/community" signInHref={`/sign-in#${pathname}`} />
9
+ */
10
+ import { createElement, useEffect, useRef, type ReactElement } from 'react'
11
+ import type {
12
+ MountBadgeOptions,
13
+ MountDiscussionOptions,
14
+ MountMemberSettingsOptions,
15
+ MountMyCommunityOptions,
16
+ MountNoticeFormOptions,
17
+ MountNotificationCentreOptions,
18
+ MountProfileOptions,
19
+ MountWelcomeOptions,
20
+ } from './mount'
21
+
22
+ export type KbDiscussionProps = Omit<MountDiscussionOptions, 'container'>
23
+ export type KbNoticeFormProps = Omit<MountNoticeFormOptions, 'container'>
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'>
30
+
31
+ /** The options that, changed, mount the element anew (functions by identity are left out). */
32
+ const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
33
+
34
+ function useMounted<P extends object>(kind: string, props: P, load: () => Promise<(o: P & { container: Element }) => () => void>): ReactElement {
35
+ const host = useRef<HTMLDivElement>(null)
36
+ // The latest props, read at mount: a parent that rebuilds an object prop on every render must not tear
37
+ // the element down (an open composer, its text) — only a changed value does.
38
+ const latest = useRef(props)
39
+ latest.current = props
40
+ const key = keyOf(props)
41
+ useEffect(() => {
42
+ let unmount: (() => void) | null = null
43
+ let gone = false
44
+ void load().then((mount) => {
45
+ if (!gone && host.current) unmount = mount({ ...latest.current, container: host.current })
46
+ })
47
+ return () => {
48
+ gone = true
49
+ unmount?.()
50
+ }
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [key])
53
+ return createElement('div', { ref: host, 'data-blogmcp-community': kind })
54
+ }
55
+
56
+ export function KbDiscussion(props: KbDiscussionProps): ReactElement {
57
+ return useMounted('discussion', props, async () => (await import('./mount')).mountDiscussion)
58
+ }
59
+
60
+ export function KbNoticeForm(props: KbNoticeFormProps): ReactElement {
61
+ return useMounted('notice-form', props, async () => (await import('./mount')).mountNoticeForm)
62
+ }
63
+
64
+ export function KbMyCommunity(props: KbMyCommunityProps): ReactElement {
65
+ return useMounted('my-community', props, async () => (await import('./mount')).mountMyCommunity)
66
+ }
67
+
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
+ }
package/src/shims.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Module shapes for the build's own imports, so a TypeScript consumer reading this package's
2
+ // sources (in-repo, before `prepack` points `types` at dist) compiles without Vue tooling.
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
6
+ export default component
7
+ }
8
+ declare module '*.css?inline' {
9
+ const css: string
10
+ export default css
11
+ }
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ /** A member's avatar preset (an emoji), else the initial of their name; decoration only (the name is beside it). */
3
+ import { computed } from 'vue'
4
+ import type { KnownAuthor } from '@blogmcp/sdk/community'
5
+ import { avatarEmoji, hueOf, initialOf } from '../core/avatars'
6
+
7
+ const props = defineProps<{ author: KnownAuthor | { kind: 'member'; memberId: string | null; displayName: string | null; avatarPreset: string | null } }>()
8
+
9
+ const emoji = computed(() => (props.author.kind === 'member' ? avatarEmoji(props.author.avatarPreset) : null))
10
+ const initial = computed(() => {
11
+ const a = props.author
12
+ if (a.kind === 'member') return initialOf(a.displayName)
13
+ if (a.kind === 'staff') return initialOf(a.displayName)
14
+ return ''
15
+ })
16
+ const background = computed(() => {
17
+ const a = props.author
18
+ if (emoji.value || !initial.value) return undefined
19
+ const id = a.kind === 'member' ? a.memberId : a.kind === 'staff' ? a.staffId : null
20
+ return `hsl(${hueOf(id)} 45% 42%)`
21
+ })
22
+ </script>
23
+
24
+ <template>
25
+ <span class="bcm-avatar" :class="{ 'bcm-avatar-initial': !emoji && initial }" :style="background ? { background } : undefined" aria-hidden="true">{{ emoji ?? initial }}</span>
26
+ </template>
@@ -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>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ /** A yes/no question in a native modal dialog (delete a message, withdraw the consent, delete the profile). */
3
+ import { ref } from 'vue'
4
+ import { uid, useModal, useText } from './context'
5
+ import type { CommunityMessageKey } from '@blogmcp/sdk/community'
6
+ import type { TextParams } from '../core/i18n'
7
+
8
+ const props = defineProps<{
9
+ title: CommunityMessageKey
10
+ explain?: CommunityMessageKey
11
+ explainParams?: TextParams
12
+ confirm: CommunityMessageKey
13
+ cancel?: CommunityMessageKey
14
+ danger?: boolean
15
+ busy?: boolean
16
+ fallbackFocus?: () => HTMLElement | null
17
+ }>()
18
+ const emit = defineEmits<{ confirm: []; cancel: [] }>()
19
+ const { t } = useText()
20
+
21
+ const dialog = ref<HTMLDialogElement | null>(null)
22
+ const id = uid('bcm-dialog')
23
+ useModal(dialog, () => emit('cancel'), props.fallbackFocus)
24
+ </script>
25
+
26
+ <template>
27
+ <dialog ref="dialog" class="bcm-dialog" :aria-labelledby="`${id}-title`" :aria-describedby="explain ? `${id}-explain` : undefined">
28
+ <div class="bcm-dialog-body">
29
+ <h3 :id="`${id}-title`">{{ t(title) }}</h3>
30
+ <p v-if="explain" :id="`${id}-explain`">{{ t(explain, explainParams) }}</p>
31
+ <div class="bcm-row">
32
+ <span class="bcm-spacer" />
33
+ <button type="button" class="bcm-btn" :disabled="busy" @click="emit('cancel')">{{ t(cancel ?? 'common.cancel') }}</button>
34
+ <button type="button" class="bcm-btn bcm-primary" :class="{ 'bcm-danger': danger }" :disabled="busy" @click="emit('confirm')">{{ t(confirm) }}</button>
35
+ </div>
36
+ </div>
37
+ </dialog>
38
+ </template>