@blogmcp/community 0.1.0 → 0.3.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 (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -0,0 +1,193 @@
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
+ * M3 (m3-contracts.md §4.3): `topic_update` (a followed conversation's new message) links like a reply; `category_topic`
12
+ * (a followed category's new topic) links the topic's page (`topicPath`, the forum's default under the site base) —
13
+ * and, of the „Články" category, the new discussion's message on its article (kb-api names the article there; its own
14
+ * sentence, notificationItemMessageKey).
15
+ *
16
+ * M3 FX (the review of wave 1, R20): kb-api names each row's conversation kind (`conversationKind`). A topic's `reply`,
17
+ * `mention` and `topic_update` link their message on the topic's page (topicMessageLink); an article's link it on the
18
+ * article while kb-api names the article — and a row of an article no longer readable stays an article's row: unlinked,
19
+ * never a topic's link, and a „Články" `category_topic` keeps its sentence of a discussion under an article.
20
+ */
21
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
22
+ import {
23
+ communityMessageLink,
24
+ topicMessageLink,
25
+ topicPath,
26
+ notificationItemMessageKey,
27
+ signInLink,
28
+ statementPath,
29
+ type CommunityMessageKey,
30
+ type CommunityTransport,
31
+ type NotificationItem,
32
+ } from '@blogmcp/sdk/community'
33
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
34
+ import { createNotificationCentre, type NotificationCentreController } from '../core/centre'
35
+ import { communityClientFor, siteBaseFor } from '../core/client'
36
+ import { formatDate } from '../core/format'
37
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
38
+ import { failureText } from '../core/outcomes'
39
+ import { provideText, uid } from './context'
40
+
41
+ defineOptions({ inheritAttrs: false })
42
+
43
+ const props = defineProps<{
44
+ apiBase?: string
45
+ siteBase?: string
46
+ lang?: string
47
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `settingsPath`, `articlePath`, … */
48
+ routes?: string | Record<string, unknown>
49
+ signInHref?: string
50
+ transport?: CommunityTransport
51
+ }>()
52
+
53
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
54
+ const { t, text } = provideText(lang)
55
+ const id = uid('bcm-centre')
56
+
57
+ const siteBase = siteBaseFor(props.siteBase)
58
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
59
+ const ctl: NotificationCentreController | null = client ? createNotificationCentre({ client }) : null
60
+ const state = ctl?.state ?? null
61
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
62
+
63
+ onMounted(() => void ctl?.load())
64
+ onBeforeUnmount(() => ctl?.dispose())
65
+
66
+ const failure = computed(() => {
67
+ const f = state?.failure
68
+ if (!f) return ''
69
+ const r = failureText(f, lang.value)
70
+ return t(r.key, r.params)
71
+ })
72
+
73
+ function signInHref(): string | null {
74
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
75
+ }
76
+
77
+ /** The row's sentence; a „Články" row whose article is no longer readable is still a discussion under an article (R20). */
78
+ const sentence = (n: NotificationItem): CommunityMessageKey | null =>
79
+ n.code === 'category_topic' && n.conversationKind === 'article' ? 'notification.category_topic.article' : notificationItemMessageKey(n)
80
+
81
+ /** The page a notification's subject is on, with its link text; null when the host has none for it. */
82
+ function subjectOf(n: NotificationItem): { href: string; key: CommunityMessageKey } | null {
83
+ if (n.decisionId) {
84
+ const href = statementPath(routes.value.statementPath, n.decisionId)
85
+ return href ? { href, key: 'sanction.statement' } : null
86
+ }
87
+ // M3 (§4.3): `category_topic` is a followed category's new topic — its page — or, of the „Články" category, a
88
+ // discussion new to its listing: kb-api names the article then, and the row links its message there (the wave 2
89
+ // integration; a row whose article is no longer readable names none and reads as a topic's).
90
+ if (n.code === 'category_topic') {
91
+ // An article's row (kb-api names its kind; an older kb-api only its article): its message on the article while the
92
+ // article is readable — else unlinked, never a topic's page.
93
+ if (n.articleId || n.conversationKind === 'article') {
94
+ const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
95
+ return href ? { href, key: 'centre.openMessage' } : null
96
+ }
97
+ const href = typeof n.conversationId === 'string' ? topicPath(routes.value.topicPath, n.conversationId) : null
98
+ return href ? { href, key: 'topic.open' } : null
99
+ }
100
+ // A reply, a mention or (M3) a followed conversation's new message: a topic's links its message on the topic's page
101
+ // (R20: kb-api names the kind), an article's its message on the article while kb-api names the article (M2 T10) — an
102
+ // article no longer readable names none, and its row stays unlinked.
103
+ if (n.code === 'reply' || n.code === 'mention' || n.code === 'topic_update') {
104
+ if (n.conversationKind === 'topic') {
105
+ const href = typeof n.conversationId === 'string' && typeof n.seq === 'number' ? topicMessageLink(routes.value.topicPath, n.conversationId, n.seq) : null
106
+ return href ? { href, key: 'centre.openMessage' } : null
107
+ }
108
+ const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
109
+ return href ? { href, key: 'centre.openMessage' } : null
110
+ }
111
+ if (n.code === 'module_push_reset') return { href: routes.value.settingsPath, key: 'centre.settings' }
112
+ if (n.code === 'module_read_only' || n.code === 'module_braked' || n.code === 'age_limit_changed' || n.code === 'erasure_scheduled' || n.code === 'inactivity_warning') {
113
+ return { href: routes.value.myCommunityPath, key: 'panel.title' }
114
+ }
115
+ return null
116
+ }
117
+
118
+ function onUnreadOnly(e: Event): void {
119
+ void ctl?.setUnreadOnly((e.target as HTMLInputElement).checked)
120
+ }
121
+
122
+ const liveText = ref('')
123
+ watch(
124
+ () => state?.announceId,
125
+ () => {
126
+ liveText.value = ''
127
+ const next = text(state?.announcement)
128
+ void nextTick(() => {
129
+ liveText.value = next
130
+ })
131
+ },
132
+ )
133
+ </script>
134
+
135
+ <template>
136
+ <section v-if="ctl && state" class="bcm bcm-centre" :lang="lang" :aria-labelledby="`${id}-title`">
137
+ <div class="bcm-row">
138
+ <h2 :id="`${id}-title`">{{ t('centre.title') }}</h2>
139
+ <span v-if="state.phase === 'ready' && ctl.unread() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unread() }) }}</span>
140
+ </div>
141
+
142
+ <p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
143
+ <div v-else-if="state.phase === 'signIn'" class="bcm-row">
144
+ <p>{{ t('centre.signIn') }}</p>
145
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
146
+ </div>
147
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
148
+ <span>{{ failure }}</span>
149
+ <button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
150
+ </div>
151
+ <template v-else>
152
+ <div class="bcm-row">
153
+ <label class="bcm-check">
154
+ <input type="checkbox" :checked="state.unreadOnly" @change="onUnreadOnly" />
155
+ <span>{{ t('centre.unreadOnly') }}</span>
156
+ </label>
157
+ <span class="bcm-spacer" />
158
+ <!-- Also while older pages are not loaded yet: their unread rows are marked by { all: true } too. -->
159
+ <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>
160
+ </div>
161
+
162
+ <p v-if="state.items.length === 0" class="bcm-muted">{{ t('centre.empty') }}</p>
163
+ <ul v-else class="bcm-list">
164
+ <li v-for="n in state.items" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
165
+ <p v-if="sentence(n)" :id="`${id}-s-${n.id}`">{{ t(sentence(n)!) }}</p>
166
+ <time :id="`${id}-t-${n.id}`" class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
167
+ <div class="bcm-row">
168
+ <a v-if="subjectOf(n)" :href="subjectOf(n)!.href">{{ t(subjectOf(n)!.key) }}</a>
169
+ <span class="bcm-spacer" />
170
+ <!-- Every row's button reads the same: its row's sentence and time tell a screen reader which one it is. -->
171
+ <button
172
+ v-if="n.readAt === null"
173
+ type="button"
174
+ class="bcm-btn bcm-small"
175
+ :aria-describedby="sentence(n) ? `${id}-s-${n.id} ${id}-t-${n.id}` : `${id}-t-${n.id}`"
176
+ :disabled="!!state.busy[`read:${n.id}`]"
177
+ @click="ctl.markRead(n.id)"
178
+ >
179
+ {{ t('panel.markRead') }}
180
+ </button>
181
+ </div>
182
+ </li>
183
+ </ul>
184
+
185
+ <div v-if="state.nextCursor !== null" class="bcm-row">
186
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('centre.loadMore') }}</button>
187
+ </div>
188
+ <p><a :href="routes.settingsPath">{{ t('centre.settings') }}</a></p>
189
+ </template>
190
+
191
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
192
+ </section>
193
+ </template>
@@ -1,14 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
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.
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).
5
7
  */
6
8
  import { computed, nextTick, onMounted, ref, watch } from 'vue'
7
9
  import { AGE_BANDS, COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_HANDLE_CHANGE_DAYS, type AgeBand, type CommunityMessageKey } from '@blogmcp/sdk/community'
8
10
  import { AVATAR_PRESETS } from '../core/avatars'
9
11
  import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
10
- import type { OnboardingModel } from '../core/onboarding'
11
- import { failureText } from '../core/outcomes'
12
+ import { onboardingFailureText, type OnboardingModel } from '../core/onboarding'
12
13
  import { uid, useText } from './context'
13
14
 
14
15
  const props = defineProps<{ model: OnboardingModel; rulesHref: string }>()
@@ -24,7 +25,7 @@ const fresh = computed(() => s.steps.includes('profile'))
24
25
  const failure = computed(() => {
25
26
  const f = s.failure
26
27
  if (!f) return ''
27
- const ref = failureText(f, lang.value)
28
+ const ref = onboardingFailureText(f, lang.value)
28
29
  return t(ref.key, ref.params)
29
30
  })
30
31
  const ageKey = (band: AgeBand): CommunityMessageKey => `onboarding.age.${band}`
@@ -91,11 +92,20 @@ onMounted(() => void nextTick(() => heading.value?.focus()))
91
92
 
92
93
  <div v-else-if="step === 'art9'" class="bcm-section">
93
94
  <h4>{{ t('onboarding.art9Title') }}</h4>
94
- <p>{{ t('onboarding.art9Lede') }}</p>
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>
95
97
  <div class="bcm-consent" tabindex="0" role="document" :aria-label="t('onboarding.art9Title')">
96
98
  <p v-for="(paragraph, i) in s.art9Paragraphs" :key="i" class="bcm-text">{{ paragraph }}</p>
97
99
  </div>
98
- <label class="bcm-check">
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">
99
109
  <input v-model="s.art9Accepted" type="checkbox" />
100
110
  <span>{{ t('onboarding.art9Accept') }}</span>
101
111
  </label>
@@ -154,6 +164,7 @@ onMounted(() => void nextTick(() => heading.value?.focus()))
154
164
  <button v-if="s.index > 0" type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="model.back()">{{ t('onboarding.back') }}</button>
155
165
  <button type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="emit('cancel')">{{ t('common.cancel') }}</button>
156
166
  <span class="bcm-spacer" />
167
+ <button v-if="model.canRefuse()" type="button" class="bcm-btn" @click="model.refuse()">{{ t('onboarding.art9Refuse') }}</button>
157
168
  <button type="submit" class="bcm-btn bcm-primary" :disabled="!model.canContinue()">
158
169
  {{ s.phase === 'sending' ? t('composer.sending') : model.isLast() ? t('onboarding.submit') : t('onboarding.continue') }}
159
170
  </button>
@@ -0,0 +1,102 @@
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" … „Důvěryhodný
5
+ * člen", M3 D-M3-7), never its number. M3 (m3-contracts.md §3.5, §4.5): „Moderátor" with the categories the member
6
+ * moderates now — an appointment, shown to forum members only (kb-api sends `moderates` to them alone). An unknown
7
+ * handle reads one line; a visitor gets the sign-in sentence (profiles are for signed-in readers, kb-api's rule).
8
+ */
9
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
10
+ import { categoryPath, signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
11
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
12
+ import { communityClientFor, siteBaseFor } from '../core/client'
13
+ import { formatMonth } from '../core/format'
14
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
15
+ import { failureText } from '../core/outcomes'
16
+ import { createProfile, type ProfileController } from '../core/profile'
17
+ import AuthorAvatar from './AuthorAvatar.vue'
18
+ import { provideText, uid } from './context'
19
+
20
+ defineOptions({ inheritAttrs: false })
21
+
22
+ const props = defineProps<{
23
+ /** The member's handle (`jana_n`, an `@` in front is fine). */
24
+ handle?: string
25
+ apiBase?: string
26
+ siteBase?: string
27
+ lang?: string
28
+ routes?: string | Record<string, unknown>
29
+ signInHref?: string
30
+ transport?: CommunityTransport
31
+ }>()
32
+
33
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
34
+ const { t } = provideText(lang)
35
+ const id = uid('bcm-profile')
36
+
37
+ const siteBase = siteBaseFor(props.siteBase)
38
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
39
+ const ctl: ProfileController | null = client ? createProfile({ client }) : null
40
+ const state = ctl?.state ?? null
41
+
42
+ onMounted(() => void ctl?.load(props.handle ?? ''))
43
+ // A host that reuses the element for another member (client-side navigation) reads that one.
44
+ watch(
45
+ () => props.handle,
46
+ (next, before) => {
47
+ if (next !== before) void ctl?.load(next ?? '')
48
+ },
49
+ )
50
+ onBeforeUnmount(() => ctl?.dispose())
51
+
52
+ const since = computed(() => (state?.memberSince ? formatMonth(lang.value, state.memberSince) : ''))
53
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
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
+ </script>
65
+
66
+ <template>
67
+ <section v-if="ctl && state" class="bcm bcm-profile" :lang="lang" :aria-labelledby="`${id}-title`">
68
+ <p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
69
+ <p v-else-if="state.phase === 'notFound'" :id="`${id}-title`" class="bcm-note">{{ t('profile.notFound') }}</p>
70
+ <div v-else-if="state.phase === 'signIn'" class="bcm-row">
71
+ <p :id="`${id}-title`">{{ t('profile.signIn') }}</p>
72
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
73
+ </div>
74
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
75
+ <span :id="`${id}-title`">{{ failure }}</span>
76
+ <button type="button" class="bcm-btn" @click="ctl.load(props.handle ?? '')">{{ t('common.retry') }}</button>
77
+ </div>
78
+ <template v-else-if="state.author && state.author.kind === 'member'">
79
+ <header class="bcm-profile-head">
80
+ <AuthorAvatar :author="state.author" />
81
+ <div class="bcm-msg-main">
82
+ <h2 :id="`${id}-title`">{{ state.author.displayName }}</h2>
83
+ <span class="bcm-handle">{{ state.author.handle }}</span>
84
+ </div>
85
+ </header>
86
+ <p v-if="state.trustKey"><span class="bcm-badge">{{ t(state.trustKey) }}</span></p>
87
+ <p v-if="state.moderates.length > 0" class="bcm-row">
88
+ <span class="bcm-badge bcm-badge-team">{{ t('role.moderator') }}</span>
89
+ <span>{{ t('profile.moderates') }}</span>
90
+ <template v-for="c in state.moderates" :key="c.slug">
91
+ <a v-if="categoryPath(routes.categoryPath, c.slug)" :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ c.name }}</a>
92
+ <span v-else>{{ c.name }}</span>
93
+ </template>
94
+ </p>
95
+ <p v-if="since" class="bcm-muted">{{ t('profile.memberSince', { date: since }) }}</p>
96
+ <section v-if="state.bio" class="bcm-section" :aria-labelledby="`${id}-bio`">
97
+ <h3 :id="`${id}-bio`">{{ t('profile.bio') }}</h3>
98
+ <p class="bcm-text">{{ state.bio }}</p>
99
+ </section>
100
+ </template>
101
+ </section>
102
+ </template>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One row of a category's list (m3-contracts.md §3.2): a topic — its title (a link to the topic's page; a missing title
4
+ * reads „Smazané téma" only for a deleted opening post, the withheld wording otherwise), pinned, closed or archived,
5
+ * „čeká na kontrolu" on the viewer's own topic waiting for review, its author, the messages everyone is shown and its
6
+ * last activity — or in „Články" an article's discussion: the article's title — M3 FX (R20): a link to the article's
7
+ * page (the host's `articlePath` with kb-api's `articleSlug`; text where the host gives no article template) — with
8
+ * its follow button.
9
+ */
10
+ import { computed } from 'vue'
11
+ import { communityArticleLink, topicPath, topicTitleOf, type CommunityMessageKey, type KnownTopicItem } from '@blogmcp/sdk/community'
12
+ import type { FollowController } from '../core/follow'
13
+ import { formatDate } from '../core/format'
14
+ import FollowToggle from './FollowToggle.vue'
15
+ import { useText } from './context'
16
+
17
+ const props = defineProps<{ item: KnownTopicItem; topicHref: string; articleHref?: string | null; follow?: FollowController | null }>()
18
+ const { t, lang } = useText()
19
+
20
+ const title = computed(() => (props.item.kind === 'topic' ? topicTitleOf(props.item) : null))
21
+ const titleText = computed(() => {
22
+ const ti = title.value
23
+ if (!ti) return ''
24
+ return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
25
+ })
26
+ const href = computed(() => (props.item.kind === 'topic' ? topicPath(props.topicHref, props.item.conversationId) : null))
27
+ /** R20: a „Články" row's article page (null without the host's article template, or for an older kb-api's row without a slug). */
28
+ const articleLink = computed(() => (props.item.kind === 'article' ? communityArticleLink(props.articleHref, props.item.articleSlug) : null))
29
+ const stateKey = computed<CommunityMessageKey | null>(() => {
30
+ if (props.item.kind !== 'topic') return null
31
+ return props.item.state === 'locked' ? 'topic.locked' : props.item.state === 'archived' ? 'topic.archived' : null
32
+ })
33
+ const authorName = computed(() => {
34
+ if (props.item.kind !== 'topic') return null
35
+ const a = props.item.author
36
+ if (a.kind === 'member') return 'displayName' in a && a.displayName ? a.displayName : t('author.deleted')
37
+ if (a.kind === 'staff' && 'displayName' in a) return a.displayName
38
+ return null
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <li class="bcm-topic-row">
44
+ <template v-if="item.kind === 'topic'">
45
+ <p class="bcm-topic-title">
46
+ <a v-if="href" :href="href" :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</a>
47
+ <span v-else :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</span>
48
+ </p>
49
+ <p class="bcm-row bcm-muted">
50
+ <span v-if="item.pinned" class="bcm-badge">{{ t('topic.pinned') }}</span>
51
+ <span v-if="stateKey" class="bcm-badge">{{ t(stateKey) }}</span>
52
+ <span v-if="item.openingState === 'held' && title?.kind === 'shown'" class="bcm-badge">{{ t('state.held') }}</span>
53
+ <span v-if="authorName">{{ authorName }}</span>
54
+ <span>{{ t('topic.count', { count: item.count }) }}</span>
55
+ <time :datetime="item.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, item.lastActivityAt) }) }}</time>
56
+ </p>
57
+ </template>
58
+ <template v-else>
59
+ <p class="bcm-topic-title">
60
+ <a v-if="articleLink" :href="articleLink">{{ item.articleTitle ?? t('search.inArticle') }}</a>
61
+ <span v-else>{{ item.articleTitle ?? t('search.inArticle') }}</span>
62
+ </p>
63
+ <p class="bcm-row bcm-muted">
64
+ <span>{{ t('discussion.count', { count: item.count }) }}</span>
65
+ <time :datetime="item.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, item.lastActivityAt) }) }}</time>
66
+ <FollowToggle v-if="follow" :ctl="follow" />
67
+ </p>
68
+ </template>
69
+ </li>
70
+ </template>