@blogmcp/community 0.0.0-stage → 0.1.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-DzjwnHyx.js +4597 -0
  5. package/dist/core/attrs.d.ts +73 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/client.d.ts +27 -0
  8. package/dist/core/discussion.d.ts +144 -0
  9. package/dist/core/download.d.ts +4 -0
  10. package/dist/core/format.d.ts +17 -0
  11. package/dist/core/i18n.d.ts +28 -0
  12. package/dist/core/names.d.ts +15 -0
  13. package/dist/core/notice.d.ts +50 -0
  14. package/dist/core/onboarding.d.ts +52 -0
  15. package/dist/core/outcomes.d.ts +28 -0
  16. package/dist/core/panel.d.ts +80 -0
  17. package/dist/core/receipt.d.ts +5 -0
  18. package/dist/core/text.d.ts +22 -0
  19. package/dist/core/turnstile.d.ts +33 -0
  20. package/dist/element.d.ts +26 -0
  21. package/dist/global.d.ts +8 -0
  22. package/dist/index.d.ts +25 -0
  23. package/dist/index.js +22 -0
  24. package/dist/mount.d.ts +65 -0
  25. package/dist/react.d.ts +17 -0
  26. package/dist/react.js +35 -0
  27. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  28. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  29. package/dist/ui/DiscussionView.vue.d.ts +33 -0
  30. package/dist/ui/LinkedText.vue.d.ts +5 -0
  31. package/dist/ui/MessageComposer.vue.d.ts +24 -0
  32. package/dist/ui/MessageItem.vue.d.ts +6 -0
  33. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  34. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  35. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  36. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  37. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  38. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  39. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  40. package/dist/ui/StatementView.vue.d.ts +15 -0
  41. package/dist/ui/context.d.ts +21 -0
  42. package/dist/ui/discussion-context.d.ts +19 -0
  43. package/dist/ui/types.d.ts +7 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/vue.d.ts +89 -0
  46. package/dist/vue.js +97 -0
  47. package/package.json +80 -4
  48. package/src/core/attrs.ts +147 -0
  49. package/src/core/avatars.ts +44 -0
  50. package/src/core/client.ts +54 -0
  51. package/src/core/discussion.ts +691 -0
  52. package/src/core/download.ts +21 -0
  53. package/src/core/format.ts +90 -0
  54. package/src/core/i18n.ts +49 -0
  55. package/src/core/names.ts +51 -0
  56. package/src/core/notice.ts +178 -0
  57. package/src/core/onboarding.ts +345 -0
  58. package/src/core/outcomes.ts +74 -0
  59. package/src/core/panel.ts +411 -0
  60. package/src/core/receipt.ts +28 -0
  61. package/src/core/text.ts +142 -0
  62. package/src/core/turnstile.ts +98 -0
  63. package/src/element.ts +109 -0
  64. package/src/global.ts +12 -0
  65. package/src/index.ts +35 -0
  66. package/src/mount.ts +127 -0
  67. package/src/react.ts +53 -0
  68. package/src/shims.d.ts +11 -0
  69. package/src/ui/AuthorAvatar.vue +26 -0
  70. package/src/ui/ConfirmDialog.vue +38 -0
  71. package/src/ui/DiscussionView.vue +294 -0
  72. package/src/ui/LinkedText.vue +18 -0
  73. package/src/ui/MessageComposer.vue +130 -0
  74. package/src/ui/MessageItem.vue +205 -0
  75. package/src/ui/MessageMenu.vue +67 -0
  76. package/src/ui/MyCommunityView.vue +369 -0
  77. package/src/ui/NoticeFormView.vue +244 -0
  78. package/src/ui/NoticeStatusView.vue +82 -0
  79. package/src/ui/OnboardingSteps.vue +163 -0
  80. package/src/ui/ReactionBar.vue +41 -0
  81. package/src/ui/ReportDialog.vue +88 -0
  82. package/src/ui/StatementView.vue +141 -0
  83. package/src/ui/context.ts +75 -0
  84. package/src/ui/discussion-context.ts +30 -0
  85. package/src/ui/styles.css +243 -0
  86. package/src/ui/types.ts +7 -0
  87. package/src/version.ts +3 -0
  88. package/src/vue.ts +116 -0
package/src/element.ts ADDED
@@ -0,0 +1,109 @@
1
+ /// <reference path="./shims.d.ts" />
2
+ /**
3
+ * The three custom elements (Vue inside a shadow root each), m1-contracts.md §10.4:
4
+ *
5
+ * <blogmcp-discussion> article-id, api-base, site-base, lang, sign-in-href, routes, poll-ms, nonce
6
+ * <blogmcp-notice-form> api-base, site-base, lang, turnstile-site-key, target | article-id, routes, nonce
7
+ * <blogmcp-my-community> api-base, site-base, lang, decision-id, routes, sign-in-href, nonce
8
+ *
9
+ * Each carries ONE inline stylesheet in its shadow root, with the CSP nonce honoured: the one given when the
10
+ * elements are defined (the script build passes `document.currentScript.nonce`), else the element's own
11
+ * `nonce` — read from the property, because a page with a CSP hides the attribute once it is connected.
12
+ */
13
+ import { defineCustomElement, type Component } from 'vue'
14
+ import DiscussionView from './ui/DiscussionView.vue'
15
+ import MyCommunityView from './ui/MyCommunityView.vue'
16
+ import NoticeFormView from './ui/NoticeFormView.vue'
17
+ import css from './ui/styles.css?inline'
18
+
19
+ /**
20
+ * Vue's ESM build reads three compile-time flags as globals, and only a bundler running Vue's own plugin
21
+ * defines them. A React site (or any bundler without `@vitejs/plugin-vue`) leaves them undefined, and mounting
22
+ * the element then throws `__VUE_PROD_DEVTOOLS__ is not defined` (the review layer met it on the drhoubicka
23
+ * storefront, 2026-10-02). Give them Vue's documented defaults when nothing did; a site that set them keeps
24
+ * its values.
25
+ */
26
+ function ensureVueFeatureFlags(): void {
27
+ const g = globalThis as Record<string, unknown>
28
+ const defaults: Record<string, boolean> = {
29
+ __VUE_OPTIONS_API__: true,
30
+ __VUE_PROD_DEVTOOLS__: false,
31
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
32
+ }
33
+ for (const [flag, value] of Object.entries(defaults)) if (typeof g[flag] === 'undefined') g[flag] = value
34
+ }
35
+
36
+ export const DISCUSSION_ELEMENT_TAG = 'blogmcp-discussion'
37
+ export const NOTICE_FORM_ELEMENT_TAG = 'blogmcp-notice-form'
38
+ export const MY_COMMUNITY_ELEMENT_TAG = 'blogmcp-my-community'
39
+
40
+ export interface DefineCommunityElementOptions {
41
+ /** Another tag name for the element. */
42
+ tag?: string
43
+ /** CSP nonce for the element's `<style>` (else each element's own `nonce`). */
44
+ nonce?: string
45
+ }
46
+
47
+ export interface DefineCommunityElementsOptions {
48
+ /** CSP nonce for the elements' `<style>`. */
49
+ nonce?: string
50
+ tags?: { discussion?: string; noticeForm?: string; myCommunity?: string }
51
+ }
52
+
53
+ /** The element's own nonce: the property (a CSP hides the attribute once connected), else the attribute. */
54
+ function ownNonce(el: HTMLElement): string | undefined {
55
+ const fromProperty = (el as HTMLElement & { nonce?: unknown }).nonce
56
+ if (typeof fromProperty === 'string' && fromProperty !== '') return fromProperty
57
+ return el.getAttribute('nonce') || undefined
58
+ }
59
+
60
+ function communityElement(component: Component, defineNonce: string | undefined): CustomElementConstructor {
61
+ // No `styles` option: the stylesheet is put in below, so a per-element nonce can be honoured.
62
+ const Base = defineCustomElement(component as Parameters<typeof defineCustomElement>[0])
63
+ return class CommunityElement extends Base {
64
+ override connectedCallback(): void {
65
+ const own = ownNonce(this)
66
+ const nonce = defineNonce || own
67
+ // The element carries the nonce it uses (the IDL property, which a CSP never hides), so what it loads
68
+ // later — the notice form's Turnstile script — gets the same one, the script build's included.
69
+ if (nonce && !own) (this as HTMLElement & { nonce: string }).nonce = nonce
70
+ const root = this.shadowRoot
71
+ if (root && !root.querySelector('style[data-bcm]')) {
72
+ const style = document.createElement('style')
73
+ style.setAttribute('data-bcm', '')
74
+ if (nonce) style.setAttribute('nonce', nonce)
75
+ style.textContent = css
76
+ root.prepend(style)
77
+ }
78
+ super.connectedCallback()
79
+ }
80
+ }
81
+ }
82
+
83
+ function define(tag: string, component: Component, nonce: string | undefined): void {
84
+ if (typeof customElements === 'undefined' || customElements.get(tag)) return
85
+ ensureVueFeatureFlags()
86
+ customElements.define(tag, communityElement(component, nonce))
87
+ }
88
+
89
+ /** Register `<blogmcp-discussion>` (once; later calls are no-ops). Browser only. */
90
+ export function defineDiscussionElement(opts: DefineCommunityElementOptions = {}): void {
91
+ define(opts.tag ?? DISCUSSION_ELEMENT_TAG, DiscussionView, opts.nonce)
92
+ }
93
+
94
+ /** Register `<blogmcp-notice-form>` (once). Browser only. */
95
+ export function defineNoticeFormElement(opts: DefineCommunityElementOptions = {}): void {
96
+ define(opts.tag ?? NOTICE_FORM_ELEMENT_TAG, NoticeFormView, opts.nonce)
97
+ }
98
+
99
+ /** Register `<blogmcp-my-community>` (once). Browser only. */
100
+ export function defineMyCommunityElement(opts: DefineCommunityElementOptions = {}): void {
101
+ define(opts.tag ?? MY_COMMUNITY_ELEMENT_TAG, MyCommunityView, opts.nonce)
102
+ }
103
+
104
+ /** Register all three (once). Browser only. */
105
+ export function defineCommunityElements(opts: DefineCommunityElementsOptions = {}): void {
106
+ defineDiscussionElement({ tag: opts.tags?.discussion, nonce: opts.nonce })
107
+ defineNoticeFormElement({ tag: opts.tags?.noticeForm, nonce: opts.nonce })
108
+ defineMyCommunityElement({ tag: opts.tags?.myCommunity, nonce: opts.nonce })
109
+ }
package/src/global.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The `<script>` build: registers `<blogmcp-discussion>`, `<blogmcp-notice-form>` and `<blogmcp-my-community>`
3
+ * with the script tag's own CSP nonce, and exposes `window.BlogmcpCommunity`.
4
+ */
5
+ import { defineCommunityElements } from './element'
6
+ import { mountDiscussion, mountMyCommunity, mountNoticeForm } from './mount'
7
+ import { VERSION } from './version'
8
+
9
+ const script = typeof document !== 'undefined' ? (document.currentScript as HTMLScriptElement | null) : null
10
+ defineCommunityElements({ nonce: script?.nonce || undefined })
11
+
12
+ export { defineCommunityElements, mountDiscussion, mountMyCommunity, mountNoticeForm, VERSION }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @blogmcp/community — the community's elements for a site's own pages (m1-contracts.md §10.4; docs: "Add
3
+ * discussions to your frontend"), over `@blogmcp/sdk/community`. Every element talks to the HOST's relative
4
+ * base (`api-base`; default `/api/community` under the site's `site-base`: a headless site's BFF from
5
+ * `@blogmcp/sdk/community/server`, or with `site-base="/blog"` a managed blog's own proxy), never to kb-api directly.
6
+ *
7
+ * - `<blogmcp-discussion>` the discussion under an article;
8
+ * - `<blogmcp-notice-form>` the DSA notice form, for anyone;
9
+ * - `<blogmcp-my-community>` the reader's „Moje komunita", a statement page, a notifier's status page;
10
+ * - `defineCommunityElements()` and one `define…Element()` each — the custom elements;
11
+ * - `mountDiscussion(…)`, `mountNoticeForm(…)`, `mountMyCommunity(…)` — mount one from code;
12
+ * - `@blogmcp/community/vue`, `@blogmcp/community/react` — `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`;
13
+ * - `@blogmcp/community/global` — one `<script>` for sites without a bundler.
14
+ */
15
+ export {
16
+ defineCommunityElements,
17
+ defineDiscussionElement,
18
+ defineNoticeFormElement,
19
+ defineMyCommunityElement,
20
+ DISCUSSION_ELEMENT_TAG,
21
+ NOTICE_FORM_ELEMENT_TAG,
22
+ MY_COMMUNITY_ELEMENT_TAG,
23
+ type DefineCommunityElementOptions,
24
+ type DefineCommunityElementsOptions,
25
+ } from './element'
26
+ export { mountDiscussion, mountNoticeForm, mountMyCommunity, type MountDiscussionOptions, type MountNoticeFormOptions, type MountMyCommunityOptions } from './mount'
27
+ export { createDiscussionController, type DiscussionController, type DiscussionControllerOptions, type DiscussionUi, type ComposerMode, type Draft } from './core/discussion'
28
+ export { createNoticeForm, type NoticeFormController, type NoticeFormOptions, type NoticeFormUi } from './core/notice'
29
+ export { createPanel, type PanelController, type PanelOptions, type PanelUi } from './core/panel'
30
+ export { createOnboarding, type OnboardingModel, type OnboardingOptions, type OnboardingStep } from './core/onboarding'
31
+ export { pickLang, type Lang } from './core/i18n'
32
+ export { AVATAR_PRESETS, type AvatarPreset } from './core/avatars'
33
+ export { DEFAULT_API_BASE } from './core/client'
34
+ export type { CommunityRoutes } from './core/attrs'
35
+ export { VERSION } from './version'
package/src/mount.ts ADDED
@@ -0,0 +1,127 @@
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 {
11
+ DISCUSSION_ELEMENT_TAG,
12
+ MY_COMMUNITY_ELEMENT_TAG,
13
+ NOTICE_FORM_ELEMENT_TAG,
14
+ defineDiscussionElement,
15
+ defineMyCommunityElement,
16
+ defineNoticeFormElement,
17
+ } from './element'
18
+
19
+ interface CommonMountOptions {
20
+ /** The host's relative base (default `/api/community` under `siteBase`): its proxy or BFF. Never kb-api's URL. */
21
+ apiBase?: string
22
+ /**
23
+ * The path the site is served under (`/blog` on a managed blog, whose host's own `/api/` is kb-api): the default
24
+ * api-base and the default pages are taken under it. A page or base the host names itself is used as it is.
25
+ */
26
+ siteBase?: string
27
+ /** `cs` or `en` (`sk` reads Czech); default the page's `<html lang>`. */
28
+ lang?: string
29
+ /** The host's community pages (statements, the panel, the rules, the notice form, a notifier's status). */
30
+ routes?: CommunityRoutes
31
+ /** CSP nonce for the element's stylesheet (and the Turnstile script). */
32
+ nonce?: string
33
+ /** A replacement transport (tests; a signing transport later). */
34
+ transport?: CommunityTransport
35
+ /** Where the element goes; default `document.body`. */
36
+ container?: Element
37
+ }
38
+
39
+ export interface MountDiscussionOptions extends CommonMountOptions {
40
+ articleId: string
41
+ /** The site's sign-in page; the current path is put in its fragment. */
42
+ signInHref?: string
43
+ /** Head poll interval in ms (default 20 000; 0 = off). */
44
+ pollMs?: number
45
+ }
46
+
47
+ export interface MountNoticeFormOptions extends CommonMountOptions {
48
+ /** What is reported: a message, a member (by id or handle) or an article. */
49
+ target?: { conversationId: string; seq: number } | { memberId: string } | { handle: string } | { articleId: string }
50
+ /** Or the article (when `target` is absent). */
51
+ articleId?: string
52
+ /** The site's Turnstile site key; without it no widget renders (kb-api then decides alone). */
53
+ turnstileSiteKey?: string
54
+ }
55
+
56
+ export interface MountMyCommunityOptions extends CommonMountOptions {
57
+ /** Open this statement of reasons (the host's statement page). */
58
+ decisionId?: string
59
+ /** The site's sign-in page. */
60
+ signInHref?: string
61
+ }
62
+
63
+ type Settable = HTMLElement & { transport?: CommunityTransport }
64
+
65
+ function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
66
+ for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
67
+ }
68
+
69
+ function place(el: Settable, o: CommonMountOptions): () => void {
70
+ // Set before the element connects, so it mounts with it.
71
+ if (o.transport) el.transport = o.transport
72
+ ;(o.container ?? document.body).appendChild(el)
73
+ return () => el.remove()
74
+ }
75
+
76
+ /** Mount `<blogmcp-discussion>`; returns the function that removes it. */
77
+ export function mountDiscussion(o: MountDiscussionOptions): () => void {
78
+ if (typeof document === 'undefined') return () => {}
79
+ defineDiscussionElement({ nonce: o.nonce })
80
+ const el = document.createElement(DISCUSSION_ELEMENT_TAG) as Settable
81
+ attrs(el, {
82
+ 'article-id': o.articleId,
83
+ 'api-base': o.apiBase,
84
+ 'site-base': o.siteBase,
85
+ lang: o.lang,
86
+ 'sign-in-href': o.signInHref,
87
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
88
+ 'poll-ms': o.pollMs,
89
+ nonce: o.nonce,
90
+ })
91
+ return place(el, o)
92
+ }
93
+
94
+ /** Mount `<blogmcp-notice-form>`; returns the function that removes it. */
95
+ export function mountNoticeForm(o: MountNoticeFormOptions): () => void {
96
+ if (typeof document === 'undefined') return () => {}
97
+ defineNoticeFormElement({ nonce: o.nonce })
98
+ const el = document.createElement(NOTICE_FORM_ELEMENT_TAG) as Settable
99
+ attrs(el, {
100
+ 'api-base': o.apiBase,
101
+ 'site-base': o.siteBase,
102
+ lang: o.lang,
103
+ 'turnstile-site-key': o.turnstileSiteKey,
104
+ target: o.target ? JSON.stringify(o.target) : undefined,
105
+ 'article-id': o.articleId,
106
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
107
+ nonce: o.nonce,
108
+ })
109
+ return place(el, o)
110
+ }
111
+
112
+ /** Mount `<blogmcp-my-community>`; returns the function that removes it. */
113
+ export function mountMyCommunity(o: MountMyCommunityOptions = {}): () => void {
114
+ if (typeof document === 'undefined') return () => {}
115
+ defineMyCommunityElement({ nonce: o.nonce })
116
+ const el = document.createElement(MY_COMMUNITY_ELEMENT_TAG) as Settable
117
+ attrs(el, {
118
+ 'api-base': o.apiBase,
119
+ 'site-base': o.siteBase,
120
+ lang: o.lang,
121
+ 'decision-id': o.decisionId,
122
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
123
+ 'sign-in-href': o.signInHref,
124
+ nonce: o.nonce,
125
+ })
126
+ return place(el, o)
127
+ }
package/src/react.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` for React (18 and 19, Next.js App Router included — this
3
+ * module is a client component): each mounts its element after hydration and removes it on unmount. Each
4
+ * renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
5
+ * sits where the component does and the page's markup is otherwise the site's alone.
6
+ *
7
+ * <KbDiscussion articleId={article.id} apiBase="/api/community" signInHref={`/sign-in#${pathname}`} />
8
+ */
9
+ import { createElement, useEffect, useRef, type ReactElement } from 'react'
10
+ import type { MountDiscussionOptions, MountMyCommunityOptions, MountNoticeFormOptions } from './mount'
11
+
12
+ export type KbDiscussionProps = Omit<MountDiscussionOptions, 'container'>
13
+ export type KbNoticeFormProps = Omit<MountNoticeFormOptions, 'container'>
14
+ export type KbMyCommunityProps = Omit<MountMyCommunityOptions, 'container'>
15
+
16
+ /** The options that, changed, mount the element anew (functions by identity are left out). */
17
+ const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
18
+
19
+ function useMounted<P extends object>(kind: string, props: P, load: () => Promise<(o: P & { container: Element }) => () => void>): ReactElement {
20
+ const host = useRef<HTMLDivElement>(null)
21
+ // The latest props, read at mount: a parent that rebuilds an object prop on every render must not tear
22
+ // the element down (an open composer, its text) — only a changed value does.
23
+ const latest = useRef(props)
24
+ latest.current = props
25
+ const key = keyOf(props)
26
+ useEffect(() => {
27
+ let unmount: (() => void) | null = null
28
+ let gone = false
29
+ void load().then((mount) => {
30
+ if (!gone && host.current) unmount = mount({ ...latest.current, container: host.current })
31
+ })
32
+ return () => {
33
+ gone = true
34
+ unmount?.()
35
+ }
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [key])
38
+ return createElement('div', { ref: host, 'data-blogmcp-community': kind })
39
+ }
40
+
41
+ export function KbDiscussion(props: KbDiscussionProps): ReactElement {
42
+ return useMounted('discussion', props, async () => (await import('./mount')).mountDiscussion)
43
+ }
44
+
45
+ export function KbNoticeForm(props: KbNoticeFormProps): ReactElement {
46
+ return useMounted('notice-form', props, async () => (await import('./mount')).mountNoticeForm)
47
+ }
48
+
49
+ export function KbMyCommunity(props: KbMyCommunityProps): ReactElement {
50
+ return useMounted('my-community', props, async () => (await import('./mount')).mountMyCommunity)
51
+ }
52
+
53
+ export type { MountDiscussionOptions, MountNoticeFormOptions, MountMyCommunityOptions }
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,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>