@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,82 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A notifier's view of their notice (DSA art. 16(5), 17; m1-contracts.md §7.7 `notices/status`): its status,
4
+ * the statement once decided — never the case's package —, and the appeal while its window is open. The
5
+ * receipt that opened it came in the address's fragment and is gone from the address bar already.
6
+ */
7
+ import { computed } from 'vue'
8
+ import { COMMUNITY_APPEAL_MAX, type NoticeStatus } from '@blogmcp/sdk/community'
9
+ import type { AppealDraft } from '../core/panel'
10
+ import { formatDate, 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 { uid, useText } from './context'
15
+
16
+ const props = defineProps<{ notice: NoticeStatus; appeal: AppealDraft; withdrawing: boolean }>()
17
+ const emit = defineEmits<{ appeal: []; withdraw: [] }>()
18
+ const { t, lang } = useText()
19
+ const id = uid('bcm-notice-status')
20
+
21
+ const n = computed(() => props.notice)
22
+ const category = computed(() => knownKey(`category.${n.value.category}`))
23
+ const status = computed(() => knownKey(`caseStatus.${n.value.status}`))
24
+ const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
25
+ const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
26
+ const closed = computed(() => (n.value.appeal.closesAt ? isPast(n.value.appeal.closesAt) : false))
27
+ const failure = computed(() => {
28
+ const f = props.appeal.failure
29
+ if (!f) return ''
30
+ const r = failureText(f, lang.value)
31
+ return t(r.key, r.params)
32
+ })
33
+ </script>
34
+
35
+ <template>
36
+ <section class="bcm-section" :aria-labelledby="`${id}-title`">
37
+ <h3 :id="`${id}-title`">{{ t('notice.statusTitle') }}</h3>
38
+ <dl class="bcm-dl">
39
+ <template v-if="category">
40
+ <dt>{{ t('notice.category') }}</dt>
41
+ <dd>{{ t(category) }}</dd>
42
+ </template>
43
+ <template v-if="status">
44
+ <dt>{{ t('notice.statusTitle') }}</dt>
45
+ <dd>{{ t(status) }}</dd>
46
+ </template>
47
+ </dl>
48
+ <p class="bcm-muted">{{ t('notice.submittedAt', { date: formatDate(lang, n.submittedAt) }) }}</p>
49
+ <p v-if="n.decidedAt" class="bcm-muted">{{ t('notice.decidedAt', { date: formatDate(lang, n.decidedAt) }) }}</p>
50
+ <template v-if="n.statement">
51
+ <h4>{{ t('notice.statement') }}</h4>
52
+ <pre class="bcm-facts">{{ n.statement }}</pre>
53
+ </template>
54
+
55
+ <section v-if="n.decidedAt" class="bcm-card" :aria-labelledby="`${id}-appeal`">
56
+ <h4 :id="`${id}-appeal`">{{ t('notice.appealTitle') }}</h4>
57
+ <template v-if="n.appeal.open">
58
+ <p>{{ t('notice.appealOpen') }}</p>
59
+ <div class="bcm-row">
60
+ <button type="button" class="bcm-btn" :disabled="withdrawing" @click="emit('withdraw')">{{ t('notice.appealWithdraw') }}</button>
61
+ </div>
62
+ </template>
63
+ <template v-else-if="n.appeal.canAppeal">
64
+ <p>{{ t('notice.appealLede') }}</p>
65
+ <p v-if="n.appeal.closesAt" class="bcm-muted">{{ t('notice.appealUntil', { date: formatDate(lang, n.appeal.closesAt) }) }}</p>
66
+ <form class="bcm-form" novalidate @submit.prevent="canSend && emit('appeal')">
67
+ <div class="bcm-field">
68
+ <label :for="`${id}-body`">{{ t('notice.appealBody') }}</label>
69
+ <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" />
70
+ <p :id="`${id}-counter`" class="bcm-counter">{{ t('statement.appealCounter', { length, max: COMMUNITY_APPEAL_MAX }) }}</p>
71
+ </div>
72
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
73
+ <div class="bcm-row">
74
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ appeal.sending ? t('statement.appealSending') : t('notice.appealSubmit') }}</button>
75
+ </div>
76
+ </form>
77
+ </template>
78
+ <p v-else-if="closed">{{ t('notice.appealClosed') }}</p>
79
+ <p v-else>{{ t('statement.appealUnavailable') }}</p>
80
+ </section>
81
+ </section>
82
+ </template>
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-notification-centre>`: the member's notifications (m2-contracts.md §3.3, §3.10) — newest first, older
4
+ * ones a page at a time, „Jen nepřečtené", one marked read or all, and the link to the settings page. Each row is the
5
+ * catalogue's sentence of its code (never anything a member wrote) and, where the host has a page for it, its
6
+ * subject's link: a decision's statement, the settings (push reset), „Moje komunita" (the module's and the account's
7
+ * notices), and (M2 T10) a reply's or a mention's message on its article — the host's `articlePath` with the slug
8
+ * and the message's anchor (`#bcm-msg-<seq>`); a host without `articlePath`, or a row without a slug or seq (an
9
+ * article no longer live, an older kb-api), keeps the sentence unlinked. A visitor gets the sign-in sentence.
10
+ */
11
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
12
+ import {
13
+ communityMessageLink,
14
+ notificationMessageKey,
15
+ signInLink,
16
+ statementPath,
17
+ type CommunityMessageKey,
18
+ type CommunityTransport,
19
+ type NotificationItem,
20
+ } from '@blogmcp/sdk/community'
21
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
22
+ import { createNotificationCentre, type NotificationCentreController } from '../core/centre'
23
+ import { communityClientFor, siteBaseFor } from '../core/client'
24
+ import { formatDate } from '../core/format'
25
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
26
+ import { failureText } from '../core/outcomes'
27
+ import { provideText, uid } from './context'
28
+
29
+ defineOptions({ inheritAttrs: false })
30
+
31
+ const props = defineProps<{
32
+ apiBase?: string
33
+ siteBase?: string
34
+ lang?: string
35
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `settingsPath`, `articlePath`, … */
36
+ routes?: string | Record<string, unknown>
37
+ signInHref?: string
38
+ transport?: CommunityTransport
39
+ }>()
40
+
41
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
42
+ const { t, text } = provideText(lang)
43
+ const id = uid('bcm-centre')
44
+
45
+ const siteBase = siteBaseFor(props.siteBase)
46
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
47
+ const ctl: NotificationCentreController | null = client ? createNotificationCentre({ client }) : null
48
+ const state = ctl?.state ?? null
49
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
50
+
51
+ onMounted(() => void ctl?.load())
52
+ onBeforeUnmount(() => ctl?.dispose())
53
+
54
+ const failure = computed(() => {
55
+ const f = state?.failure
56
+ if (!f) return ''
57
+ const r = failureText(f, lang.value)
58
+ return t(r.key, r.params)
59
+ })
60
+
61
+ function signInHref(): string | null {
62
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
63
+ }
64
+
65
+ const sentence = (n: NotificationItem): CommunityMessageKey | null => notificationMessageKey(n.code)
66
+
67
+ /** The page a notification's subject is on, with its link text; null when the host has none for it. */
68
+ function subjectOf(n: NotificationItem): { href: string; key: CommunityMessageKey } | null {
69
+ if (n.decisionId) {
70
+ const href = statementPath(routes.value.statementPath, n.decisionId)
71
+ return href ? { href, key: 'sanction.statement' } : null
72
+ }
73
+ if (n.code === 'reply' || n.code === 'mention') {
74
+ const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
75
+ return href ? { href, key: 'centre.openMessage' } : null
76
+ }
77
+ if (n.code === 'module_push_reset') return { href: routes.value.settingsPath, key: 'centre.settings' }
78
+ if (n.code === 'module_read_only' || n.code === 'module_braked' || n.code === 'age_limit_changed' || n.code === 'erasure_scheduled' || n.code === 'inactivity_warning') {
79
+ return { href: routes.value.myCommunityPath, key: 'panel.title' }
80
+ }
81
+ return null
82
+ }
83
+
84
+ function onUnreadOnly(e: Event): void {
85
+ void ctl?.setUnreadOnly((e.target as HTMLInputElement).checked)
86
+ }
87
+
88
+ const liveText = ref('')
89
+ watch(
90
+ () => state?.announceId,
91
+ () => {
92
+ liveText.value = ''
93
+ const next = text(state?.announcement)
94
+ void nextTick(() => {
95
+ liveText.value = next
96
+ })
97
+ },
98
+ )
99
+ </script>
100
+
101
+ <template>
102
+ <section v-if="ctl && state" class="bcm bcm-centre" :lang="lang" :aria-labelledby="`${id}-title`">
103
+ <div class="bcm-row">
104
+ <h2 :id="`${id}-title`">{{ t('centre.title') }}</h2>
105
+ <span v-if="state.phase === 'ready' && ctl.unread() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unread() }) }}</span>
106
+ </div>
107
+
108
+ <p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
109
+ <div v-else-if="state.phase === 'signIn'" class="bcm-row">
110
+ <p>{{ t('centre.signIn') }}</p>
111
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
112
+ </div>
113
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
114
+ <span>{{ failure }}</span>
115
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
116
+ </div>
117
+ <template v-else>
118
+ <div class="bcm-row">
119
+ <label class="bcm-check">
120
+ <input type="checkbox" :checked="state.unreadOnly" @change="onUnreadOnly" />
121
+ <span>{{ t('centre.unreadOnly') }}</span>
122
+ </label>
123
+ <span class="bcm-spacer" />
124
+ <!-- Also while older pages are not loaded yet: their unread rows are marked by { all: true } too. -->
125
+ <button v-if="ctl.unread() > 0 || state.nextCursor !== null" type="button" class="bcm-btn bcm-small" :disabled="!!state.busy.all" @click="ctl.markAllRead()">{{ t('centre.markAllRead') }}</button>
126
+ </div>
127
+
128
+ <p v-if="state.items.length === 0" class="bcm-muted">{{ t('centre.empty') }}</p>
129
+ <ul v-else class="bcm-list">
130
+ <li v-for="n in state.items" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
131
+ <p v-if="sentence(n)" :id="`${id}-s-${n.id}`">{{ t(sentence(n)!) }}</p>
132
+ <time :id="`${id}-t-${n.id}`" class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
133
+ <div class="bcm-row">
134
+ <a v-if="subjectOf(n)" :href="subjectOf(n)!.href">{{ t(subjectOf(n)!.key) }}</a>
135
+ <span class="bcm-spacer" />
136
+ <!-- Every row's button reads the same: its row's sentence and time tell a screen reader which one it is. -->
137
+ <button
138
+ v-if="n.readAt === null"
139
+ type="button"
140
+ class="bcm-btn bcm-small"
141
+ :aria-describedby="sentence(n) ? `${id}-s-${n.id} ${id}-t-${n.id}` : `${id}-t-${n.id}`"
142
+ :disabled="!!state.busy[`read:${n.id}`]"
143
+ @click="ctl.markRead(n.id)"
144
+ >
145
+ {{ t('panel.markRead') }}
146
+ </button>
147
+ </div>
148
+ </li>
149
+ </ul>
150
+
151
+ <div v-if="state.nextCursor !== null" class="bcm-row">
152
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('centre.loadMore') }}</button>
153
+ </div>
154
+ <p><a :href="routes.settingsPath">{{ t('centre.settings') }}</a></p>
155
+ </template>
156
+
157
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
158
+ </section>
159
+ </template>
@@ -0,0 +1,174 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The inline onboarding (core/onboarding.ts): one step at a time, the focus moved to each step's heading,
4
+ * the rules as text with a link to the full page, the art. 9 text as text paragraphs — shown once, with one consent
5
+ * under `joint` and one per controller under `split` (each labelled with its controller, all required), and
6
+ * „Nesouhlasím" where the tenant's refusal leaves the public discussion open (m2-contracts.md §3.8).
7
+ */
8
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
9
+ import { AGE_BANDS, COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_HANDLE_CHANGE_DAYS, type AgeBand, type CommunityMessageKey } from '@blogmcp/sdk/community'
10
+ import { AVATAR_PRESETS } from '../core/avatars'
11
+ import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
12
+ import { onboardingFailureText, type OnboardingModel } from '../core/onboarding'
13
+ import { uid, useText } from './context'
14
+
15
+ const props = defineProps<{ model: OnboardingModel; rulesHref: string }>()
16
+ const emit = defineEmits<{ cancel: [] }>()
17
+ const { t, lang } = useText()
18
+
19
+ const s = props.model.state
20
+ const id = uid('bcm-onboarding')
21
+ const heading = ref<HTMLElement | null>(null)
22
+ const step = computed(() => props.model.step())
23
+ const fresh = computed(() => s.steps.includes('profile'))
24
+
25
+ const failure = computed(() => {
26
+ const f = s.failure
27
+ if (!f) return ''
28
+ const ref = onboardingFailureText(f, lang.value)
29
+ return t(ref.key, ref.params)
30
+ })
31
+ const ageKey = (band: AgeBand): CommunityMessageKey => `onboarding.age.${band}`
32
+
33
+ // The focus goes to the step's heading on a new step and on a refusal; not while sending (the button keeps it).
34
+ // Steps read again after a refusal (a new text, a step added) are a new step too, at the same index or not.
35
+ watch(
36
+ () => [s.index, s.phase, s.steps.join()] as const,
37
+ ([index, phase, steps], [indexBefore, , stepsBefore]) => {
38
+ if (index !== indexBefore || steps !== stepsBefore || phase === 'refused' || phase === 'failed') void nextTick(() => heading.value?.focus())
39
+ },
40
+ )
41
+ onMounted(() => void nextTick(() => heading.value?.focus()))
42
+ </script>
43
+
44
+ <template>
45
+ <section class="bcm-card" :aria-labelledby="`${id}-title`">
46
+ <h3 :id="`${id}-title`" ref="heading" tabindex="-1">{{ t('onboarding.title') }}</h3>
47
+
48
+ <p v-if="s.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
49
+
50
+ <template v-else-if="s.phase === 'failed'">
51
+ <p class="bcm-error" role="alert">{{ failure }}</p>
52
+ <div class="bcm-row">
53
+ <button type="button" class="bcm-btn" @click="emit('cancel')">{{ t('common.cancel') }}</button>
54
+ <button type="button" class="bcm-btn bcm-primary" @click="model.load()">{{ t('common.retry') }}</button>
55
+ </div>
56
+ </template>
57
+
58
+ <template v-else-if="s.phase === 'refused'">
59
+ <p role="alert">{{ t('onboarding.ageBelow', { min: s.minAge ?? 18 }) }}</p>
60
+ <div class="bcm-row">
61
+ <button type="button" class="bcm-btn" @click="emit('cancel')">{{ t('common.close') }}</button>
62
+ </div>
63
+ </template>
64
+
65
+ <form v-else class="bcm-form" novalidate @submit.prevent="model.next()">
66
+ <p v-if="s.index === 0 && fresh" class="bcm-hint">{{ t('onboarding.lede') }}</p>
67
+
68
+ <fieldset v-if="step === 'age'" class="bcm-choices">
69
+ <legend>{{ t('onboarding.ageTitle') }}</legend>
70
+ <label v-for="band in AGE_BANDS" :key="band" class="bcm-choice">
71
+ <input v-model="s.ageBand" type="radio" :name="`${id}-age`" :value="band" />
72
+ <span>{{ t(ageKey(band)) }}</span>
73
+ </label>
74
+ <p class="bcm-hint bcm-full">{{ t('onboarding.ageHint') }}</p>
75
+ </fieldset>
76
+
77
+ <div v-else-if="step === 'rules'" class="bcm-section">
78
+ <h4>{{ t('onboarding.rulesTitle') }}</h4>
79
+ <p>{{ s.reconsent ? t('onboarding.rulesChanged') : t('onboarding.rulesLede') }}</p>
80
+ <ol v-if="s.rules.length" class="bcm-rules">
81
+ <li v-for="rule in s.rules" :key="rule.id">
82
+ <strong>{{ rule.title }}</strong>
83
+ <span v-if="rule.summary" class="bcm-hint">{{ rule.summary }}</span>
84
+ </li>
85
+ </ol>
86
+ <p><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesLink') }}</a></p>
87
+ <label class="bcm-check">
88
+ <input v-model="s.rulesAccepted" type="checkbox" />
89
+ <span>{{ t('onboarding.rulesAccept') }}</span>
90
+ </label>
91
+ </div>
92
+
93
+ <div v-else-if="step === 'art9'" class="bcm-section">
94
+ <h4>{{ t('onboarding.art9Title') }}</h4>
95
+ <!-- Where a refusal leaves the public discussion open, the lede does not make writing depend on the consent. -->
96
+ <p>{{ model.refusalOffered() ? t('onboarding.art9LedeOptional') : t('onboarding.art9Lede') }}</p>
97
+ <div class="bcm-consent" tabindex="0" role="document" :aria-label="t('onboarding.art9Title')">
98
+ <p v-for="(paragraph, i) in s.art9Paragraphs" :key="i" class="bcm-text">{{ paragraph }}</p>
99
+ </div>
100
+ <!-- split: one consent per controller, grouped, each one required (the form is novalidate: `required` is announced). -->
101
+ <fieldset v-if="s.art9Controllers.length > 1" class="bcm-fieldset">
102
+ <legend class="bcm-sr">{{ t('onboarding.art9Title') }}</legend>
103
+ <label v-for="c in s.art9Controllers" :key="c.entity" class="bcm-check">
104
+ <input v-model="c.accepted" type="checkbox" required />
105
+ <span>{{ t('onboarding.art9Controller', { label: c.label }) }}</span>
106
+ </label>
107
+ </fieldset>
108
+ <label v-else class="bcm-check">
109
+ <input v-model="s.art9Accepted" type="checkbox" />
110
+ <span>{{ t('onboarding.art9Accept') }}</span>
111
+ </label>
112
+ </div>
113
+
114
+ <div v-else-if="step === 'profile'" class="bcm-section">
115
+ <h4>{{ t('onboarding.profileTitle') }}</h4>
116
+ <div class="bcm-field">
117
+ <label :for="`${id}-name`">{{ t('onboarding.displayName') }}</label>
118
+ <input
119
+ :id="`${id}-name`"
120
+ v-model="s.displayName"
121
+ class="bcm-input"
122
+ autocomplete="nickname"
123
+ :maxlength="COMMUNITY_DISPLAY_NAME_MAX * 2"
124
+ :aria-invalid="model.hasFieldError('displayName')"
125
+ :aria-describedby="`${id}-name-hint`"
126
+ />
127
+ <p :id="`${id}-name-hint`" class="bcm-hint">{{ t('onboarding.displayNameHint') }}</p>
128
+ </div>
129
+ <div class="bcm-field">
130
+ <label :for="`${id}-handle`">{{ t('onboarding.handle') }}</label>
131
+ <input
132
+ :id="`${id}-handle`"
133
+ v-model="s.handle"
134
+ class="bcm-input"
135
+ autocomplete="username"
136
+ autocapitalize="none"
137
+ spellcheck="false"
138
+ :maxlength="HANDLE_MAX"
139
+ :placeholder="model.suggestedHandle() ?? ''"
140
+ :aria-invalid="model.hasFieldError('handle')"
141
+ :aria-describedby="`${id}-handle-hint`"
142
+ />
143
+ <p :id="`${id}-handle-hint`" class="bcm-hint">
144
+ {{ t('onboarding.handleHint', { min: HANDLE_MIN, max: HANDLE_MAX, days: COMMUNITY_HANDLE_CHANGE_DAYS }) }}
145
+ </p>
146
+ <p v-if="model.suggestedHandle()" class="bcm-hint">{{ t('onboarding.handleSuggested', { handle: model.suggestedHandle() ?? '' }) }}</p>
147
+ </div>
148
+ <fieldset class="bcm-choices">
149
+ <legend>{{ t('onboarding.avatar') }}</legend>
150
+ <label class="bcm-choice">
151
+ <input v-model="s.avatarPreset" type="radio" :name="`${id}-avatar`" :value="null" />
152
+ <span>{{ t('onboarding.avatarNone') }}</span>
153
+ </label>
154
+ <label v-for="preset in AVATAR_PRESETS" :key="preset.id" class="bcm-choice">
155
+ <input v-model="s.avatarPreset" type="radio" :name="`${id}-avatar`" :value="preset.id" />
156
+ <span>{{ preset.emoji }}</span>
157
+ </label>
158
+ </fieldset>
159
+ </div>
160
+
161
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
162
+
163
+ <div class="bcm-row">
164
+ <button v-if="s.index > 0" type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="model.back()">{{ t('onboarding.back') }}</button>
165
+ <button type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="emit('cancel')">{{ t('common.cancel') }}</button>
166
+ <span class="bcm-spacer" />
167
+ <button v-if="model.canRefuse()" type="button" class="bcm-btn" @click="model.refuse()">{{ t('onboarding.art9Refuse') }}</button>
168
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!model.canContinue()">
169
+ {{ s.phase === 'sending' ? t('composer.sending') : model.isLast() ? t('onboarding.submit') : t('onboarding.continue') }}
170
+ </button>
171
+ </div>
172
+ </form>
173
+ </section>
174
+ </template>
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-profile handle>`: a member's public profile (m2-contracts.md §3.2, §3.10) — the name and avatar, since
4
+ * when they are a member (a month), the bio as text, and the trust level as its label („Nový člen" … „Moderátor"),
5
+ * never its number. An unknown handle reads one line; a visitor gets the sign-in sentence (profiles are for signed-in
6
+ * readers, kb-api's rule).
7
+ */
8
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
9
+ import { signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
10
+ import { communityClientFor, siteBaseFor } from '../core/client'
11
+ import { formatMonth } from '../core/format'
12
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
13
+ import { failureText } from '../core/outcomes'
14
+ import { createProfile, type ProfileController } from '../core/profile'
15
+ import AuthorAvatar from './AuthorAvatar.vue'
16
+ import { provideText, uid } from './context'
17
+
18
+ defineOptions({ inheritAttrs: false })
19
+
20
+ const props = defineProps<{
21
+ /** The member's handle (`jana_n`, an `@` in front is fine). */
22
+ handle?: string
23
+ apiBase?: string
24
+ siteBase?: string
25
+ lang?: string
26
+ routes?: string | Record<string, unknown>
27
+ signInHref?: string
28
+ transport?: CommunityTransport
29
+ }>()
30
+
31
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
32
+ const { t } = provideText(lang)
33
+ const id = uid('bcm-profile')
34
+
35
+ const siteBase = siteBaseFor(props.siteBase)
36
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
37
+ const ctl: ProfileController | null = client ? createProfile({ client }) : null
38
+ const state = ctl?.state ?? null
39
+
40
+ onMounted(() => void ctl?.load(props.handle ?? ''))
41
+ // A host that reuses the element for another member (client-side navigation) reads that one.
42
+ watch(
43
+ () => props.handle,
44
+ (next, before) => {
45
+ if (next !== before) void ctl?.load(next ?? '')
46
+ },
47
+ )
48
+ onBeforeUnmount(() => ctl?.dispose())
49
+
50
+ const since = computed(() => (state?.memberSince ? formatMonth(lang.value, state.memberSince) : ''))
51
+ const failure = computed(() => {
52
+ const f = state?.failure
53
+ if (!f) return ''
54
+ const r = failureText(f, lang.value)
55
+ return t(r.key, r.params)
56
+ })
57
+
58
+ function signInHref(): string | null {
59
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <section v-if="ctl && state" class="bcm bcm-profile" :lang="lang" :aria-labelledby="`${id}-title`">
65
+ <p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
66
+ <p v-else-if="state.phase === 'notFound'" :id="`${id}-title`" class="bcm-note">{{ t('profile.notFound') }}</p>
67
+ <div v-else-if="state.phase === 'signIn'" class="bcm-row">
68
+ <p :id="`${id}-title`">{{ t('profile.signIn') }}</p>
69
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
70
+ </div>
71
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
72
+ <span :id="`${id}-title`">{{ failure }}</span>
73
+ <button type="button" class="bcm-btn" @click="ctl.load(props.handle ?? '')">{{ t('common.retry') }}</button>
74
+ </div>
75
+ <template v-else-if="state.author && state.author.kind === 'member'">
76
+ <header class="bcm-profile-head">
77
+ <AuthorAvatar :author="state.author" />
78
+ <div class="bcm-msg-main">
79
+ <h2 :id="`${id}-title`">{{ state.author.displayName }}</h2>
80
+ <span class="bcm-handle">{{ state.author.handle }}</span>
81
+ </div>
82
+ </header>
83
+ <p v-if="state.trustKey"><span class="bcm-badge">{{ t(state.trustKey) }}</span></p>
84
+ <p v-if="since" class="bcm-muted">{{ t('profile.memberSince', { date: since }) }}</p>
85
+ <section v-if="state.bio" class="bcm-section" :aria-labelledby="`${id}-bio`">
86
+ <h3 :id="`${id}-bio`">{{ t('profile.bio') }}</h3>
87
+ <p class="bcm-text">{{ state.bio }}</p>
88
+ </section>
89
+ </template>
90
+ </section>
91
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Reactions from the tenant's set, counts only (who reacted is never shown in a public space). The viewer's
4
+ * own is a pressed button — a thicker border, a bold count and a spoken „Vaše reakce", not colour alone.
5
+ * A reader who cannot react sees the counts that are not zero.
6
+ */
7
+ import { computed } from 'vue'
8
+ import type { ReactionSummary } from '@blogmcp/sdk/community'
9
+ import { useText } from './context'
10
+
11
+ const props = defineProps<{
12
+ reactionSet: readonly string[]
13
+ summary: ReactionSummary
14
+ interactive: boolean
15
+ busy: boolean
16
+ }>()
17
+ const emit = defineEmits<{ react: [emoji: string] }>()
18
+ const { t } = useText()
19
+
20
+ const shown = computed(() => (props.interactive ? props.reactionSet : props.reactionSet.filter((e) => (props.summary.counts[e] ?? 0) > 0)))
21
+ </script>
22
+
23
+ <template>
24
+ <div v-if="shown.length" class="bcm-reactions" role="group" :aria-label="t('discussion.reactions')">
25
+ <template v-for="emoji in shown" :key="emoji">
26
+ <button
27
+ v-if="interactive"
28
+ type="button"
29
+ class="bcm-react"
30
+ :aria-pressed="summary.mine === emoji"
31
+ :disabled="busy"
32
+ :title="summary.mine === emoji ? t('discussion.reactionRemove') : t('discussion.reactionAdd')"
33
+ @click="emit('react', emoji)"
34
+ >
35
+ <span>{{ t('discussion.reactionCount', { emoji, count: summary.counts[emoji] ?? 0 }) }}</span>
36
+ <span v-if="summary.mine === emoji" class="bcm-sr">{{ t('discussion.reactionMine') }}</span>
37
+ </button>
38
+ <span v-else class="bcm-react">{{ t('discussion.reactionCount', { emoji, count: summary.counts[emoji] ?? 0 }) }}</span>
39
+ </template>
40
+ </div>
41
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A member's report of a message (`messages/report`): the category (community.md §5's list), the rule of
4
+ * the tenant's rules text when the category is the rules, and the one free text — a note only the team
5
+ * reads; a link to the full rules (community.md §4.5, in a new tab: the dialog stays). The outcome comes back
6
+ * in „Moje komunita".
7
+ */
8
+ import { computed, ref } from 'vue'
9
+ import { CASE_CATEGORIES, COMMUNITY_REPORT_NOTE_MAX, type CaseCategory, type CommunityMessageKey } from '@blogmcp/sdk/community'
10
+ import type { ReportDraft } from '../core/discussion'
11
+ import { failureText } from '../core/outcomes'
12
+ import { codePointLength, normalizeBody } from '../core/text'
13
+ import { uid, useModal, useText } from './context'
14
+
15
+ const props = defineProps<{ report: ReportDraft; myCommunityHref: string; rulesHref?: string; fallbackFocus?: () => HTMLElement | null }>()
16
+ const emit = defineEmits<{ submit: []; close: [] }>()
17
+ const { t, lang } = useText()
18
+
19
+ const dialog = ref<HTMLDialogElement | null>(null)
20
+ const id = uid('bcm-report')
21
+ useModal(dialog, () => emit('close'), props.fallbackFocus)
22
+
23
+ const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
24
+ const noteLength = computed(() => codePointLength(normalizeBody(props.report.note)))
25
+ const canSend = computed(
26
+ () =>
27
+ !props.report.sending &&
28
+ props.report.category !== '' &&
29
+ (props.report.category !== 'rules' || props.report.ruleId !== '') &&
30
+ noteLength.value <= COMMUNITY_REPORT_NOTE_MAX,
31
+ )
32
+ const failure = computed(() => {
33
+ const f = props.report.failure
34
+ if (!f) return ''
35
+ const ref = failureText(f, lang.value)
36
+ return t(ref.key, ref.params)
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <dialog ref="dialog" class="bcm-dialog" :aria-labelledby="`${id}-title`">
42
+ <form class="bcm-dialog-body" novalidate @submit.prevent="canSend && emit('submit')">
43
+ <h3 :id="`${id}-title`">{{ t('report.title') }}</h3>
44
+ <template v-if="!report.done">
45
+ <p class="bcm-hint">{{ t('report.lede') }}</p>
46
+ <p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
47
+ <fieldset class="bcm-choices">
48
+ <legend>{{ t('report.category') }}</legend>
49
+ <label v-for="c in CASE_CATEGORIES" :key="c" class="bcm-choice">
50
+ <input v-model="report.category" type="radio" :name="`${id}-category`" :value="c" />
51
+ <span>{{ t(categoryKey(c)) }}</span>
52
+ </label>
53
+ </fieldset>
54
+ <div v-if="report.category === 'rules'" class="bcm-field">
55
+ <label :for="`${id}-rule`">{{ t('report.rule') }}</label>
56
+ <p v-if="report.rulesFailed" class="bcm-error">{{ t('error.unavailable') }}</p>
57
+ <p v-else-if="report.rules === null" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
58
+ <select v-else :id="`${id}-rule`" v-model="report.ruleId" class="bcm-input" required>
59
+ <option value="" disabled />
60
+ <option v-for="rule in report.rules" :key="rule.id" :value="rule.id">{{ rule.title }}</option>
61
+ </select>
62
+ </div>
63
+ <div class="bcm-field">
64
+ <label :for="`${id}-note`">{{ t('report.note') }}</label>
65
+ <textarea :id="`${id}-note`" v-model="report.note" class="bcm-input bcm-textarea" rows="3" :aria-describedby="`${id}-note-hint`" :aria-invalid="noteLength > COMMUNITY_REPORT_NOTE_MAX" />
66
+ <p :id="`${id}-note-hint`" class="bcm-hint">
67
+ {{ t('report.noteHint') }}
68
+ {{ t('report.counter', { length: noteLength, max: COMMUNITY_REPORT_NOTE_MAX }) }}
69
+ </p>
70
+ </div>
71
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
72
+ <div class="bcm-row">
73
+ <span class="bcm-spacer" />
74
+ <button type="button" class="bcm-btn" :disabled="report.sending" @click="emit('close')">{{ t('report.cancel') }}</button>
75
+ <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ report.sending ? t('report.sending') : t('report.submit') }}</button>
76
+ </div>
77
+ </template>
78
+ <template v-else>
79
+ <p role="status">{{ t('report.done') }}</p>
80
+ <p><a :href="myCommunityHref">{{ t('panel.title') }}</a></p>
81
+ <div class="bcm-row">
82
+ <span class="bcm-spacer" />
83
+ <button type="button" class="bcm-btn bcm-primary" @click="emit('close')">{{ t('common.close') }}</button>
84
+ </div>
85
+ </template>
86
+ </form>
87
+ </dialog>
88
+ </template>