@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
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A statement of reasons (DSA art. 17(3); m1-contracts.md §7.3 `me/decisions/get`) and its redress: the
4
+ * stored fields as text — the facts in a `<pre class="bcm-facts">` —, the subject as its author may see it,
5
+ * the whole statement kb-api rendered, and the appeal within its window (or the open appeal's state).
6
+ */
7
+ import { computed } from 'vue'
8
+ import { COMMUNITY_APPEAL_MAX, type Statement } from '@blogmcp/sdk/community'
9
+ import type { AppealDraft } from '../core/panel'
10
+ import { formatDate, formatTerritorialScope, isPast } from '../core/format'
11
+ import { knownKey } from '../core/i18n'
12
+ import { failureText } from '../core/outcomes'
13
+ import { codePointLength, normalizeBody } from '../core/text'
14
+ import LinkedText from './LinkedText.vue'
15
+ import { uid, useText } from './context'
16
+
17
+ const props = defineProps<{ statement: Statement; appeal: AppealDraft; withdrawing: boolean }>()
18
+ const emit = defineEmits<{ appeal: []; withdraw: [] }>()
19
+ const { t, lang } = useText()
20
+ const id = uid('bcm-statement')
21
+
22
+ const s = computed(() => props.statement)
23
+ const restriction = computed(() => knownKey(`restriction.${s.value.restriction}`))
24
+ const basis = computed(() => knownKey(`basis.${s.value.basis}`))
25
+ const category = computed(() => knownKey(`category.${s.value.category}`))
26
+ /** `all` in words, a list of regions by their names; a value of a newer kb-api as it came. */
27
+ const scope = computed(() => formatTerritorialScope(lang.value, s.value.territorialScope))
28
+ const appealStatus = computed(() => (s.value.appeal ? knownKey(`appealStatus.${s.value.appeal.status}`) : null))
29
+ const subjectKey = computed(() =>
30
+ s.value.subject.kind === 'message' ? 'statement.subjectMessage' : s.value.subject.kind === 'article' ? 'statement.subjectArticle' : 'statement.subjectProfile',
31
+ )
32
+ const subjectText = computed(() => {
33
+ const subject = s.value.subject
34
+ return subject.kind === 'message' && subject.message ? (subject.message.body ?? subject.message.displayBody) : null
35
+ })
36
+ const appealOpen = computed(() => s.value.appeal?.status === 'open')
37
+ const windowClosed = computed(() => isPast(s.value.appealWindowClosesAt))
38
+ const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
39
+ const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
40
+ const failure = computed(() => {
41
+ const f = props.appeal.failure
42
+ if (!f) return ''
43
+ const r = failureText(f, lang.value)
44
+ return t(r.key, r.params)
45
+ })
46
+ </script>
47
+
48
+ <template>
49
+ <article class="bcm-section" :aria-labelledby="`${id}-title`">
50
+ <h3 :id="`${id}-title`">{{ t('statement.title') }}</h3>
51
+ <p class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, s.decidedAt) }) }}</p>
52
+ <p>{{ t('statement.provider', { entity: s.providerEntity }) }}</p>
53
+
54
+ <dl class="bcm-dl">
55
+ <template v-if="restriction">
56
+ <dt>{{ t('statement.restriction') }}</dt>
57
+ <dd>
58
+ {{ t(restriction) }}
59
+ <span v-if="s.restrictionUntil" class="bcm-muted">{{ t('statement.until', { date: formatDate(lang, s.restrictionUntil) }) }}</span>
60
+ </dd>
61
+ </template>
62
+ <template v-if="category">
63
+ <dt>{{ t('report.category') }}</dt>
64
+ <dd>{{ t(category) }}</dd>
65
+ </template>
66
+ <template v-if="scope">
67
+ <dt>{{ t('statement.scope') }}</dt>
68
+ <dd>{{ scope }}</dd>
69
+ </template>
70
+ <template v-if="basis">
71
+ <dt>{{ t('statement.basis') }}</dt>
72
+ <dd>{{ t(basis) }}</dd>
73
+ </template>
74
+ <dt>{{ t('statement.automation') }}</dt>
75
+ <dd>
76
+ <span v-if="s.automatedDetection">{{ t('statement.automatedDetection') }}</span>
77
+ <span>{{ s.automated ? t('statement.automatedDecision') : t('statement.humanDecision') }}</span>
78
+ </dd>
79
+ <template v-if="s.legalGround">
80
+ <dt>{{ t('statement.legalGround') }}</dt>
81
+ <dd>
82
+ <p>{{ s.legalGround }}</p>
83
+ <p v-if="s.legalExplanation" class="bcm-text">{{ s.legalExplanation }}</p>
84
+ </dd>
85
+ </template>
86
+ <template v-if="s.contractualGround">
87
+ <dt>{{ t('statement.contractualGround') }}</dt>
88
+ <dd>
89
+ <p>{{ s.contractualGround }}</p>
90
+ <p v-if="s.contractualExplanation" class="bcm-text">{{ s.contractualExplanation }}</p>
91
+ </dd>
92
+ </template>
93
+ <dt>{{ t('statement.subject') }}</dt>
94
+ <dd>
95
+ <p>{{ t(subjectKey) }}</p>
96
+ <p v-if="subjectText" class="bcm-note bcm-text"><LinkedText :text="subjectText" /></p>
97
+ </dd>
98
+ </dl>
99
+
100
+ <h4>{{ t('statement.facts') }}</h4>
101
+ <pre class="bcm-facts">{{ s.facts }}</pre>
102
+
103
+ <h4>{{ t('statement.redress') }}</h4>
104
+ <p class="bcm-text">{{ s.redress }}</p>
105
+
106
+ <details>
107
+ <summary>{{ t('statement.full') }}</summary>
108
+ <pre class="bcm-facts">{{ s.statement }}</pre>
109
+ </details>
110
+
111
+ <section class="bcm-card" :aria-labelledby="`${id}-appeal`">
112
+ <h4 :id="`${id}-appeal`">{{ t('statement.appealTitle') }}</h4>
113
+ <template v-if="appealOpen">
114
+ <p>{{ t('statement.appealOpen') }}</p>
115
+ <p v-if="appealStatus" class="bcm-muted">{{ t(appealStatus) }}</p>
116
+ <div class="bcm-row">
117
+ <button type="button" class="bcm-btn" :disabled="withdrawing" @click="emit('withdraw')">{{ t('statement.appealWithdraw') }}</button>
118
+ </div>
119
+ </template>
120
+ <template v-else-if="s.canAppeal">
121
+ <p>{{ t('statement.appealLede') }}</p>
122
+ <p class="bcm-muted">{{ t('statement.appealUntil', { date: formatDate(lang, s.appealWindowClosesAt) }) }}</p>
123
+ <form class="bcm-form" novalidate @submit.prevent="canSend && emit('appeal')">
124
+ <div class="bcm-field">
125
+ <label :for="`${id}-body`">{{ t('statement.appealBody') }}</label>
126
+ <textarea :id="`${id}-body`" v-model="appeal.body" class="bcm-input bcm-textarea" rows="5" :aria-describedby="`${id}-counter`" :aria-invalid="length > COMMUNITY_APPEAL_MAX" />
127
+ <p :id="`${id}-counter`" class="bcm-counter">{{ t('statement.appealCounter', { length, max: COMMUNITY_APPEAL_MAX }) }}</p>
128
+ </div>
129
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
130
+ <div class="bcm-row">
131
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ appeal.sending ? t('statement.appealSending') : t('statement.appealSubmit') }}</button>
132
+ </div>
133
+ </form>
134
+ </template>
135
+ <template v-else>
136
+ <p v-if="appealStatus" class="bcm-muted">{{ t(appealStatus) }}</p>
137
+ <p>{{ windowClosed ? t('statement.appealClosed') : t('statement.appealUnavailable') }}</p>
138
+ </template>
139
+ </section>
140
+ </article>
141
+ </template>
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What the components of one element share: the language and its texts (`t` reads the catalogue through
3
+ * `communityMessage` and nothing else), ids unique to the element, and the dialog's focus handling.
4
+ */
5
+ import { inject, nextTick, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
6
+ import { communityMessage } from '@blogmcp/sdk/community'
7
+ import type { Lang, TextRef, Translate } from '../core/i18n'
8
+
9
+ export interface TextContext {
10
+ lang: Ref<Lang>
11
+ t: Translate
12
+ /** A TextRef (a controller's outcome) as text. */
13
+ text(ref: TextRef | null | undefined): string
14
+ }
15
+
16
+ const TEXT: InjectionKey<TextContext> = Symbol('bcm-text')
17
+
18
+ export function provideText(lang: Ref<Lang>): TextContext {
19
+ // Reads lang.value on every call, so a view that renders a text re-renders when the language changes.
20
+ const t: Translate = (key, params) => communityMessage(lang.value, key, params)
21
+ const ctx: TextContext = { lang, t, text: (ref) => (ref ? t(ref.key, ref.params) : '') }
22
+ provide(TEXT, ctx)
23
+ return ctx
24
+ }
25
+
26
+ export function useText(): TextContext {
27
+ const ctx = inject(TEXT, null)
28
+ if (!ctx) throw new Error('[blogmcp-community] a component rendered outside its element')
29
+ return ctx
30
+ }
31
+
32
+ let counter = 0
33
+ /** An id unique on the page (ids live in each element's shadow root, but the labels reference them). */
34
+ export function uid(prefix: string): string {
35
+ counter += 1
36
+ return `${prefix}-${counter}`
37
+ }
38
+
39
+ /**
40
+ * A native `<dialog>` opened modally while the component lives: the top layer, Esc, the focus kept
41
+ * inside — and returned to where it was (or to `fallback` when that is gone, e.g. a deleted message's menu).
42
+ */
43
+ export function useModal(dialog: Ref<HTMLDialogElement | null>, onCancel: () => void, fallback?: () => HTMLElement | null): void {
44
+ let previous: HTMLElement | null = null
45
+ const onCancelEvent = (e: Event): void => {
46
+ e.preventDefault()
47
+ onCancel()
48
+ }
49
+ onMounted(() => {
50
+ const el = dialog.value
51
+ if (!el) return
52
+ const root = el.getRootNode() as Document | ShadowRoot
53
+ previous = (root.activeElement as HTMLElement | null) ?? null
54
+ el.addEventListener('cancel', onCancelEvent)
55
+ if (typeof el.showModal === 'function') {
56
+ try {
57
+ el.showModal()
58
+ } catch {
59
+ el.setAttribute('open', '')
60
+ }
61
+ } else {
62
+ el.setAttribute('open', '')
63
+ }
64
+ })
65
+ onBeforeUnmount(() => {
66
+ const el = dialog.value
67
+ el?.removeEventListener('cancel', onCancelEvent)
68
+ if (el?.open && typeof el.close === 'function') el.close()
69
+ const back = previous
70
+ void nextTick(() => {
71
+ const target = back && back.isConnected ? back : (fallback?.() ?? null)
72
+ target?.focus()
73
+ })
74
+ })
75
+ }
@@ -0,0 +1,30 @@
1
+ /** What the discussion's components share: the controller, the host's routes, what the reader opened. */
2
+ import { inject, provide, type ComputedRef, type InjectionKey } from 'vue'
3
+ import type { DiscussionController } from '../core/discussion'
4
+ import type { routesWithDefaults } from '../core/attrs'
5
+
6
+ export interface DiscussionContext {
7
+ ctl: DiscussionController
8
+ routes: ComputedRef<ReturnType<typeof routesWithDefaults>>
9
+ /** Messages of a blocked author the reader opened anyway. */
10
+ opened: Set<number>
11
+ /** Translated messages the reader reads in the original. */
12
+ originals: Set<number>
13
+ /** Top-level messages whose every reply the reader asked to see. */
14
+ allReplies: Set<number>
15
+ /** Where the focus goes when the element it came from is gone (a deleted message). */
16
+ focusHeading(): HTMLElement | null
17
+ deleteMessage(seq: number): void
18
+ }
19
+
20
+ const KEY: InjectionKey<DiscussionContext> = Symbol('bcm-discussion')
21
+
22
+ export function provideDiscussion(ctx: DiscussionContext): void {
23
+ provide(KEY, ctx)
24
+ }
25
+
26
+ export function useDiscussion(): DiscussionContext {
27
+ const ctx = inject(KEY, null)
28
+ if (!ctx) throw new Error('[blogmcp-community] a discussion component rendered outside <blogmcp-discussion>')
29
+ return ctx
30
+ }
@@ -0,0 +1,243 @@
1
+ /* The community elements' one stylesheet. It lives in each element's shadow root: nothing here reaches the
2
+ site, nothing of the site's reaches here but what :host lets through. A site themes the elements with the
3
+ --bcm-* custom properties (set them on the element: `blogmcp-discussion { --bcm-accent: … }`). */
4
+
5
+ :host {
6
+ all: initial;
7
+ display: block;
8
+ --bcm-bg: #ffffff;
9
+ --bcm-fg: #1d1f23;
10
+ --bcm-muted: #5f6672;
11
+ --bcm-line: #dfe2e7;
12
+ --bcm-soft: #f4f5f7;
13
+ --bcm-accent: #2457d6;
14
+ --bcm-accent-fg: #ffffff;
15
+ --bcm-danger: #b42318;
16
+ --bcm-ok: #1f7a3f;
17
+ --bcm-warn-bg: #fff6d6;
18
+ --bcm-shadow: 0 8px 28px rgba(16, 24, 40, 0.18);
19
+ --bcm-z: 20;
20
+ color-scheme: light dark;
21
+ font: var(--bcm-font-size, 16px) / 1.5 var(--bcm-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
22
+ color: var(--bcm-fg);
23
+ }
24
+ :host([hidden]) { display: none; }
25
+
26
+ @media (prefers-color-scheme: dark) {
27
+ :host {
28
+ --bcm-bg: #1c1f24;
29
+ --bcm-fg: #eceef1;
30
+ --bcm-muted: #a4abb6;
31
+ --bcm-line: #343943;
32
+ --bcm-soft: #262a31;
33
+ --bcm-accent: #6d93ff;
34
+ --bcm-accent-fg: #0d1117;
35
+ --bcm-danger: #ff8a7a;
36
+ --bcm-ok: #6fd08e;
37
+ --bcm-warn-bg: #3a3420;
38
+ --bcm-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
39
+ }
40
+ }
41
+
42
+ * { box-sizing: border-box; }
43
+ h2, h3, h4 { margin: 0; font-weight: 650; line-height: 1.3; }
44
+ h2 { font-size: 1.25em; }
45
+ h3 { font-size: 1.05em; }
46
+ h4 { font-size: 1em; }
47
+ p { margin: 0; }
48
+ ul, ol { margin: 0; padding: 0; }
49
+ a { color: var(--bcm-accent); }
50
+
51
+ .bcm {
52
+ background: var(--bcm-bg);
53
+ color: var(--bcm-fg);
54
+ border-radius: 14px;
55
+ padding: 16px;
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 16px;
59
+ max-width: 100%;
60
+ overflow-wrap: anywhere;
61
+ }
62
+
63
+ /* visually hidden, read by screen readers */
64
+ .bcm-sr {
65
+ position: absolute !important;
66
+ width: 1px; height: 1px; padding: 0; margin: -1px;
67
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
68
+ }
69
+
70
+ /* ── controls ── */
71
+ .bcm-btn {
72
+ font: inherit;
73
+ display: inline-flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ gap: 6px;
77
+ min-height: 36px;
78
+ padding: 6px 14px;
79
+ border-radius: 8px;
80
+ border: 1px solid var(--bcm-line);
81
+ background: var(--bcm-bg);
82
+ color: var(--bcm-fg);
83
+ cursor: pointer;
84
+ text-decoration: none;
85
+ }
86
+ .bcm-btn:hover:not(:disabled) { background: var(--bcm-soft); }
87
+ .bcm-btn:disabled { opacity: 0.55; cursor: default; }
88
+ .bcm-primary { background: var(--bcm-accent); border-color: var(--bcm-accent); color: var(--bcm-accent-fg); }
89
+ .bcm-primary:hover:not(:disabled) { background: var(--bcm-accent); filter: brightness(1.08); }
90
+ .bcm-danger { color: var(--bcm-danger); }
91
+ .bcm-primary.bcm-danger { background: var(--bcm-danger); border-color: var(--bcm-danger); color: var(--bcm-bg); }
92
+ .bcm-small { min-height: 30px; padding: 2px 10px; font-size: 0.875em; }
93
+ .bcm-link { font: inherit; border: 0; background: none; padding: 0; color: var(--bcm-accent); cursor: pointer; text-align: left; text-decoration: underline; }
94
+ .bcm-quiet { font: inherit; border: 0; background: none; padding: 4px 6px; color: var(--bcm-muted); cursor: pointer; border-radius: 6px; min-height: 30px; }
95
+ .bcm-quiet:hover:not(:disabled) { background: var(--bcm-soft); color: var(--bcm-fg); }
96
+ .bcm-btn:focus-visible, .bcm-link:focus-visible, .bcm-quiet:focus-visible, .bcm-input:focus-visible, .bcm-react:focus-visible, a:focus-visible,
97
+ .bcm-choice input:focus-visible + span {
98
+ outline: 2px solid var(--bcm-accent);
99
+ outline-offset: 2px;
100
+ }
101
+
102
+ .bcm-input {
103
+ font: inherit;
104
+ width: 100%;
105
+ padding: 8px 10px;
106
+ border-radius: 8px;
107
+ border: 1px solid var(--bcm-line);
108
+ background: var(--bcm-bg);
109
+ color: var(--bcm-fg);
110
+ }
111
+ .bcm-input[aria-invalid='true'] { border-color: var(--bcm-danger); border-width: 2px; }
112
+ .bcm-textarea { resize: vertical; min-height: 96px; }
113
+ .bcm-field { display: flex; flex-direction: column; gap: 4px; }
114
+ .bcm-field > label, .bcm-label { font-weight: 600; }
115
+ .bcm-check { display: flex; gap: 8px; align-items: flex-start; }
116
+ .bcm-check input { margin-top: 0.3em; width: 18px; height: 18px; flex: none; }
117
+ .bcm-choices { display: flex; flex-wrap: wrap; gap: 8px; border: 0; margin: 0; padding: 0; min-width: 0; }
118
+ .bcm-choices legend, .bcm-fieldset legend { font-weight: 600; padding: 0; margin-bottom: 6px; }
119
+ .bcm-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
120
+ .bcm-choice { position: relative; display: inline-flex; }
121
+ .bcm-choice input { position: absolute; opacity: 0; width: 1px; height: 1px; }
122
+ .bcm-choice span {
123
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
124
+ min-height: 40px; min-width: 40px; padding: 6px 12px;
125
+ border: 1px solid var(--bcm-line); border-radius: 999px; cursor: pointer; background: var(--bcm-bg);
126
+ }
127
+ /* The chosen one: a thicker border and a check, not colour alone. */
128
+ .bcm-choice input:checked + span { border: 2px solid var(--bcm-accent); font-weight: 650; }
129
+ .bcm-choice input:checked + span::before { content: '✓'; }
130
+
131
+ /* ── text ── */
132
+ .bcm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
133
+ .bcm-spacer { flex: 1; }
134
+ .bcm-full { flex-basis: 100%; }
135
+ .bcm-muted, .bcm-hint { color: var(--bcm-muted); font-size: 0.875em; }
136
+ .bcm-error { color: var(--bcm-danger); }
137
+ .bcm-ok { color: var(--bcm-ok); }
138
+ .bcm-note { background: var(--bcm-soft); border-radius: 8px; padding: 8px 12px; font-size: 0.9375em; }
139
+ .bcm-warning { background: var(--bcm-warn-bg); border-radius: 8px; padding: 8px 12px; font-size: 0.9375em; }
140
+ .bcm-banner {
141
+ background: var(--bcm-warn-bg); border-radius: 10px; padding: 10px 12px;
142
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
143
+ }
144
+ .bcm-text { white-space: pre-wrap; overflow-wrap: anywhere; }
145
+ .bcm-facts {
146
+ font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0;
147
+ background: var(--bcm-soft); border-radius: 8px; padding: 10px 12px;
148
+ }
149
+ .bcm-badge {
150
+ display: inline-flex; align-items: center; gap: 4px;
151
+ border: 1px solid currentColor; border-radius: 999px; padding: 0 8px;
152
+ font-size: 0.8125em; line-height: 1.6;
153
+ }
154
+ .bcm-badge-team { color: var(--bcm-accent); font-weight: 650; }
155
+
156
+ /* ── the discussion ── */
157
+ .bcm-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
158
+ .bcm-count { color: var(--bcm-muted); }
159
+ .bcm-thread, .bcm-replies { list-style: none; display: flex; flex-direction: column; gap: 12px; }
160
+ .bcm-replies { margin-top: 12px; padding-left: 20px; border-left: 2px solid var(--bcm-line); }
161
+ .bcm-msg { display: flex; gap: 10px; align-items: flex-start; }
162
+ .bcm-msg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
163
+ .bcm-msg-pending { opacity: 0.65; }
164
+ .bcm-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; font-size: 0.875em; }
165
+ .bcm-author { font-weight: 650; }
166
+ .bcm-handle, .bcm-time { color: var(--bcm-muted); }
167
+ .bcm-handle::before { content: '@'; }
168
+ .bcm-placeholder { color: var(--bcm-muted); font-style: italic; }
169
+ .bcm-own-state { border-left: 3px solid var(--bcm-line); padding-left: 10px; display: flex; flex-direction: column; gap: 4px; }
170
+ .bcm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 2px 6px; }
171
+ .bcm-avatar {
172
+ flex: none; width: 36px; height: 36px; border-radius: 999px;
173
+ display: inline-flex; align-items: center; justify-content: center;
174
+ background: var(--bcm-soft); font-size: 20px; line-height: 1; user-select: none;
175
+ }
176
+ .bcm-avatar-initial { color: #fff; font-size: 16px; font-weight: 650; }
177
+ .bcm-reactions { display: flex; flex-wrap: wrap; gap: 6px; }
178
+ .bcm-react {
179
+ font: inherit; display: inline-flex; align-items: center; gap: 4px;
180
+ min-height: 32px; padding: 2px 10px; border-radius: 999px;
181
+ border: 1px solid var(--bcm-line); background: var(--bcm-bg); color: var(--bcm-fg); cursor: pointer;
182
+ }
183
+ button.bcm-react:hover:not(:disabled) { background: var(--bcm-soft); }
184
+ /* The viewer's own: a thicker border and a bold count, not colour alone. */
185
+ .bcm-react[aria-pressed='true'] { border: 2px solid var(--bcm-accent); font-weight: 700; }
186
+ span.bcm-react { cursor: default; }
187
+ .bcm-menu { position: relative; display: inline-flex; }
188
+ .bcm-menu-list {
189
+ position: absolute; right: 0; top: calc(100% + 4px); z-index: var(--bcm-z);
190
+ min-width: 200px; display: flex; flex-direction: column; padding: 4px;
191
+ background: var(--bcm-bg); border: 1px solid var(--bcm-line); border-radius: 10px; box-shadow: var(--bcm-shadow);
192
+ }
193
+ .bcm-menu-list button, .bcm-menu-list a {
194
+ font: inherit; text-align: left; border: 0; background: none; color: var(--bcm-fg);
195
+ padding: 8px 10px; border-radius: 6px; cursor: pointer; text-decoration: none; min-height: 36px;
196
+ }
197
+ .bcm-menu-list button:hover, .bcm-menu-list a:hover { background: var(--bcm-soft); }
198
+
199
+ /* ── the composer ── */
200
+ .bcm-composer { display: flex; flex-direction: column; gap: 8px; }
201
+ .bcm-composer-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
202
+ .bcm-counter { color: var(--bcm-muted); font-size: 0.8125em; font-variant-numeric: tabular-nums; }
203
+ .bcm-counter-over { color: var(--bcm-danger); font-weight: 650; }
204
+ .bcm-confirm { border: 2px solid var(--bcm-accent); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
205
+
206
+ /* ── onboarding, forms, the panel ── */
207
+ .bcm-card { border: 1px solid var(--bcm-line); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
208
+ .bcm-steps { color: var(--bcm-muted); font-size: 0.875em; }
209
+ .bcm-rules { list-style: none; display: flex; flex-direction: column; gap: 8px; }
210
+ .bcm-rules li { display: flex; flex-direction: column; gap: 2px; }
211
+ .bcm-consent { max-height: 260px; overflow: auto; border: 1px solid var(--bcm-line); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
212
+ .bcm-form { display: flex; flex-direction: column; gap: 14px; }
213
+ .bcm-section { display: flex; flex-direction: column; gap: 10px; }
214
+ .bcm-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
215
+ .bcm-list > li { border: 1px solid var(--bcm-line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
216
+ .bcm-unread { border-left: 4px solid var(--bcm-accent) !important; }
217
+ .bcm-dl { display: grid; grid-template-columns: minmax(120px, max-content) 1fr; gap: 6px 14px; margin: 0; }
218
+ .bcm-dl dt { font-weight: 600; }
219
+ .bcm-dl dd { margin: 0; }
220
+ .bcm-receipt { display: flex; flex-direction: column; gap: 8px; }
221
+ .bcm-turnstile { min-height: 0; }
222
+
223
+ /* ── dialogs (native <dialog>: the top layer, Esc, focus kept inside) ── */
224
+ .bcm-dialog {
225
+ border: 1px solid var(--bcm-line); border-radius: 14px; padding: 0;
226
+ width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
227
+ background: var(--bcm-bg); color: var(--bcm-fg); box-shadow: var(--bcm-shadow);
228
+ }
229
+ .bcm-dialog::backdrop { background: rgba(10, 12, 16, 0.45); }
230
+ .bcm-dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
231
+
232
+ @media (max-width: 480px) {
233
+ .bcm { padding: 12px; border-radius: 0; }
234
+ .bcm-replies { padding-left: 12px; }
235
+ .bcm-avatar { width: 30px; height: 30px; font-size: 17px; }
236
+ .bcm-dl { grid-template-columns: 1fr; }
237
+ .bcm-dialog { width: 100vw; max-width: 100vw; margin: auto 0 0; border-radius: 16px 16px 0 0; }
238
+ }
239
+
240
+ @media (prefers-reduced-motion: no-preference) {
241
+ .bcm-dialog[open] { animation: bcm-in 140ms ease-out; }
242
+ @keyframes bcm-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
243
+ }
@@ -0,0 +1,7 @@
1
+ /** One action of a message's menu: a button, or a link (the notice page for a reader without a session). */
2
+ export interface MenuItem {
3
+ id: string
4
+ label: string
5
+ href?: string
6
+ danger?: boolean
7
+ }
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** This package's version; the build reads it from package.json. */
2
+ declare const __BLOGMCP_COMMUNITY_VERSION__: string | undefined
3
+ export const VERSION: string = typeof __BLOGMCP_COMMUNITY_VERSION__ === 'string' ? __BLOGMCP_COMMUNITY_VERSION__ : '0.0.0-dev'
package/src/vue.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` for Vue / Nuxt: each mounts its element on the
3
+ * client, after hydration, and removes it on unmount. Each renders one empty `<div>` as the mount point —
4
+ * nothing else, on the server or the client — so the element sits where the component does and the page's
5
+ * markup is otherwise the site's alone.
6
+ *
7
+ * <KbDiscussion :article-id="article.id" :sign-in-href="localePath('/login')" />
8
+ *
9
+ * A site served under a path names it: `site-base="/blog"` on a managed blog (its proxy is `/blog/api/community`;
10
+ * the host's own `/api/` there is kb-api, which no element may call).
11
+ */
12
+ import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
13
+ import type { CommunityTransport } from '@blogmcp/sdk/community'
14
+ import type { CommunityRoutes } from './core/attrs'
15
+ import type { MountDiscussionOptions, MountMyCommunityOptions, MountNoticeFormOptions } from './mount'
16
+
17
+ type Mounter<O> = (o: O & { container: Element }) => () => void
18
+
19
+ /** The shared life of a wrapper: mount after hydration, remount when an option changes, unmount with it. */
20
+ function useMount<O extends object>(kind: string, options: () => O, load: () => Promise<Mounter<O>>) {
21
+ const host = ref<HTMLElement | null>(null)
22
+ let unmount: (() => void) | null = null
23
+ let gone = false
24
+ let ticket = 0
25
+ async function mount(): Promise<void> {
26
+ const mine = ++ticket
27
+ const mountFn = await load()
28
+ if (gone || mine !== ticket || !host.value) return
29
+ unmount?.()
30
+ unmount = mountFn({ ...options(), container: host.value })
31
+ }
32
+ onMounted(() => void mount())
33
+ watch(
34
+ () => JSON.stringify(options(), (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v)),
35
+ () => void mount(),
36
+ )
37
+ onBeforeUnmount(() => {
38
+ gone = true
39
+ unmount?.()
40
+ unmount = null
41
+ })
42
+ return () => h('div', { ref: host, 'data-blogmcp-community': kind })
43
+ }
44
+
45
+ function defined<T extends object>(o: T): T {
46
+ const out = {} as T
47
+ for (const [k, v] of Object.entries(o)) if (v !== undefined && v !== null) (out as Record<string, unknown>)[k] = v
48
+ return out
49
+ }
50
+
51
+ export const KbDiscussion = defineComponent({
52
+ name: 'KbDiscussion',
53
+ props: {
54
+ articleId: { type: String, required: true },
55
+ apiBase: String,
56
+ siteBase: String,
57
+ lang: String,
58
+ signInHref: String,
59
+ routes: Object as PropType<CommunityRoutes>,
60
+ pollMs: Number,
61
+ nonce: String,
62
+ transport: Function as PropType<CommunityTransport>,
63
+ },
64
+ setup(props) {
65
+ return useMount<MountDiscussionOptions>(
66
+ 'discussion',
67
+ () => defined({ ...props }) as MountDiscussionOptions,
68
+ async () => (await import('./mount')).mountDiscussion as Mounter<MountDiscussionOptions>,
69
+ )
70
+ },
71
+ })
72
+
73
+ export const KbNoticeForm = defineComponent({
74
+ name: 'KbNoticeForm',
75
+ props: {
76
+ target: Object as PropType<MountNoticeFormOptions['target']>,
77
+ articleId: String,
78
+ apiBase: String,
79
+ siteBase: String,
80
+ lang: String,
81
+ turnstileSiteKey: String,
82
+ routes: Object as PropType<CommunityRoutes>,
83
+ nonce: String,
84
+ transport: Function as PropType<CommunityTransport>,
85
+ },
86
+ setup(props) {
87
+ return useMount<MountNoticeFormOptions>(
88
+ 'notice-form',
89
+ () => defined({ ...props }) as MountNoticeFormOptions,
90
+ async () => (await import('./mount')).mountNoticeForm as Mounter<MountNoticeFormOptions>,
91
+ )
92
+ },
93
+ })
94
+
95
+ export const KbMyCommunity = defineComponent({
96
+ name: 'KbMyCommunity',
97
+ props: {
98
+ decisionId: String,
99
+ apiBase: String,
100
+ siteBase: String,
101
+ lang: String,
102
+ signInHref: String,
103
+ routes: Object as PropType<CommunityRoutes>,
104
+ nonce: String,
105
+ transport: Function as PropType<CommunityTransport>,
106
+ },
107
+ setup(props) {
108
+ return useMount<MountMyCommunityOptions>(
109
+ 'my-community',
110
+ () => defined({ ...props }) as MountMyCommunityOptions,
111
+ async () => (await import('./mount')).mountMyCommunity as Mounter<MountMyCommunityOptions>,
112
+ )
113
+ },
114
+ })
115
+
116
+ export type { MountDiscussionOptions, MountNoticeFormOptions, MountMyCommunityOptions, CommunityRoutes }