@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.
- package/README.md +162 -13
- package/dist/blogmcp-community.global.js +8 -8
- package/dist/chunks/mount--2_V2q3M.js +10063 -0
- package/dist/core/attrs.d.ts +42 -1
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +120 -15
- package/dist/core/follow.d.ts +26 -0
- package/dist/core/format.d.ts +7 -0
- package/dist/core/forum-category.d.ts +85 -0
- package/dist/core/forum-search.d.ts +35 -0
- package/dist/core/forum-state.d.ts +30 -0
- package/dist/core/forum-words.d.ts +8 -0
- package/dist/core/forum.d.ts +21 -0
- package/dist/core/moderation.d.ts +80 -0
- package/dist/core/onboarding.d.ts +49 -2
- package/dist/core/outcomes.d.ts +2 -1
- package/dist/core/panel.d.ts +17 -1
- package/dist/core/profile.d.ts +45 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +46 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +13 -0
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +47 -1
- package/dist/global.d.ts +8 -4
- package/dist/index.d.ts +20 -3
- package/dist/index.js +62 -12
- package/dist/mount.d.ts +89 -0
- package/dist/react.d.ts +27 -3
- package/dist/react.js +48 -4
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/DiscussionView.vue.d.ts +11 -0
- package/dist/ui/FollowButtonView.vue.d.ts +17 -0
- package/dist/ui/FollowToggle.vue.d.ts +6 -0
- package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
- package/dist/ui/ForumModerationView.vue.d.ts +12 -0
- package/dist/ui/ForumNotices.vue.d.ts +7 -0
- package/dist/ui/ForumSearchView.vue.d.ts +11 -0
- package/dist/ui/ForumView.vue.d.ts +12 -0
- package/dist/ui/LinkedText.vue.d.ts +1 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +8 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +273 -3
- package/dist/vue.js +235 -5
- package/package.json +3 -3
- package/src/core/attrs.ts +61 -2
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +497 -46
- package/src/core/follow.ts +139 -0
- package/src/core/format.ts +22 -0
- package/src/core/forum-category.ts +323 -0
- package/src/core/forum-search.ts +153 -0
- package/src/core/forum-state.ts +59 -0
- package/src/core/forum-words.ts +38 -0
- package/src/core/forum.ts +83 -0
- package/src/core/moderation.ts +414 -0
- package/src/core/onboarding.ts +177 -18
- package/src/core/outcomes.ts +23 -1
- package/src/core/panel.ts +39 -4
- package/src/core/profile.ts +155 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +52 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +32 -0
- package/src/core/topic.ts +234 -0
- package/src/element.ts +124 -6
- package/src/global.ts +40 -4
- package/src/index.ts +84 -2
- package/src/mount.ts +250 -1
- package/src/react.ts +90 -3
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/DiscussionView.vue +43 -3
- package/src/ui/FollowButtonView.vue +85 -0
- package/src/ui/FollowToggle.vue +39 -0
- package/src/ui/ForumCategoryView.vue +243 -0
- package/src/ui/ForumModerationView.vue +309 -0
- package/src/ui/ForumNotices.vue +23 -0
- package/src/ui/ForumSearchView.vue +171 -0
- package/src/ui/ForumView.vue +128 -0
- package/src/ui/LinkedText.vue +11 -5
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +64 -11
- package/src/ui/MessageItem.vue +36 -7
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +12 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +193 -0
- package/src/ui/OnboardingSteps.vue +17 -6
- package/src/ui/ProfileView.vue +102 -0
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +30 -0
- package/src/vue.ts +271 -3
- 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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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>
|