@blogmcp/community 0.2.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 +64 -12
- package/dist/blogmcp-community.global.js +7 -7
- package/dist/chunks/{mount-WadUnaTa.js → mount--2_V2q3M.js} +4724 -1097
- package/dist/core/attrs.d.ts +17 -2
- package/dist/core/discussion.d.ts +92 -18
- package/dist/core/follow.d.ts +26 -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 +7 -0
- package/dist/core/profile.d.ts +6 -1
- package/dist/core/realtime.d.ts +13 -2
- package/dist/core/topic.d.ts +47 -0
- package/dist/element.d.ts +26 -1
- package/dist/global.d.ts +7 -6
- package/dist/index.d.ts +13 -4
- package/dist/index.js +54 -27
- package/dist/mount.d.ts +50 -1
- package/dist/react.d.ts +17 -4
- package/dist/react.js +32 -8
- 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/MembersOnly.vue.d.ts +15 -0
- package/dist/ui/MessageComposer.vue.d.ts +6 -2
- package/dist/ui/MessageMenu.vue.d.ts +2 -0
- package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
- package/dist/ui/TopicRow.vue.d.ts +10 -0
- package/dist/ui/TopicView.vue.d.ts +32 -0
- package/dist/ui/context.d.ts +7 -1
- package/dist/ui/discussion-context.d.ts +11 -3
- package/dist/vue.d.ts +164 -5
- package/dist/vue.js +134 -8
- package/package.json +3 -3
- package/src/core/attrs.ts +22 -2
- package/src/core/discussion.ts +309 -43
- package/src/core/follow.ts +139 -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 +16 -4
- package/src/core/outcomes.ts +10 -0
- package/src/core/profile.ts +12 -1
- package/src/core/realtime.ts +15 -2
- package/src/core/topic.ts +234 -0
- package/src/element.ts +63 -1
- package/src/global.ts +27 -5
- package/src/index.ts +49 -2
- package/src/mount.ts +144 -2
- package/src/react.ts +45 -2
- 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/MembersOnly.vue +59 -0
- package/src/ui/MessageComposer.vue +51 -9
- package/src/ui/MessageItem.vue +12 -1
- package/src/ui/MessageMenu.vue +3 -2
- package/src/ui/ModeratorDialog.vue +102 -0
- package/src/ui/MyCommunityView.vue +3 -0
- package/src/ui/NoticeStatusView.vue +15 -2
- package/src/ui/NotificationCentreView.vue +37 -3
- package/src/ui/ProfileView.vue +15 -4
- package/src/ui/TopicRow.vue +70 -0
- package/src/ui/TopicView.vue +395 -0
- package/src/ui/context.ts +8 -3
- package/src/ui/discussion-context.ts +11 -3
- package/src/ui/styles.css +14 -0
- package/src/vue.ts +145 -3
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** A refused age band, per element (as the discussion's: kb-api keeps nothing of it, §7.4.2). */
|
|
3
|
+
const refusedAges = new WeakMap<object, number>()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<script setup lang="ts">
|
|
7
|
+
/**
|
|
8
|
+
* `<blogmcp-topic conversation-id>` (m3-contracts.md §3.1–§3.5, §5.3): one forum topic — the discussion's view reused
|
|
9
|
+
* over the topic's thread (its messages, replies two levels deep, reactions, the composer, edits, reports, blocks, the
|
|
10
|
+
* onboarding with the art. 9 consent a forum write needs, live updates through the `realtime` property or the head
|
|
11
|
+
* poll), read with the forum's words, under the topic's header: its title (a deleted opening post reads „Smazané
|
|
12
|
+
* téma", a title withheld from this viewer its own words), closed, archived or pinned, its category, the follow button
|
|
13
|
+
* and, for a moderator of its category, the moderator's menu (close or reopen, pin, move; on a message: remove, pass to
|
|
14
|
+
* the team). A closed topic says so where the composer was; away mode and the forum's pause read one sentence each. The
|
|
15
|
+
* forum is for its members: anyone else reads one sentence and the way in; a topic the viewer may not see reads as
|
|
16
|
+
* not found; a forum switched off renders nothing. Dispatches `bcm-posted` as the discussion does.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
|
|
19
|
+
import {
|
|
20
|
+
categoryPath,
|
|
21
|
+
communityMessageAnchor,
|
|
22
|
+
communityMessageSeqFromHash,
|
|
23
|
+
statementPath,
|
|
24
|
+
topicTitleOf,
|
|
25
|
+
type CommunityTransport,
|
|
26
|
+
type DiscussionMessage,
|
|
27
|
+
type DiscussionRestriction,
|
|
28
|
+
} from '@blogmcp/sdk/community'
|
|
29
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
30
|
+
import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
|
|
31
|
+
import { formatDate } from '../core/format'
|
|
32
|
+
import { forumNoticesOf, pausedKey } from '../core/forum-state'
|
|
33
|
+
import { FORUM_WORDS } from '../core/forum-words'
|
|
34
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
35
|
+
import { failureText } from '../core/outcomes'
|
|
36
|
+
import type { TopicRealtime } from '../core/realtime'
|
|
37
|
+
import { createTopicController, type TopicController } from '../core/topic'
|
|
38
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
39
|
+
import FollowToggle from './FollowToggle.vue'
|
|
40
|
+
import ForumNotices from './ForumNotices.vue'
|
|
41
|
+
import MembersOnly from './MembersOnly.vue'
|
|
42
|
+
import MessageComposer from './MessageComposer.vue'
|
|
43
|
+
import MessageItem from './MessageItem.vue'
|
|
44
|
+
import MessageMenu from './MessageMenu.vue'
|
|
45
|
+
import ModeratorDialog from './ModeratorDialog.vue'
|
|
46
|
+
import OnboardingSteps from './OnboardingSteps.vue'
|
|
47
|
+
import ReportDialog from './ReportDialog.vue'
|
|
48
|
+
import { provideText, uid } from './context'
|
|
49
|
+
import { provideDiscussion } from './discussion-context'
|
|
50
|
+
import type { MenuItem } from './types'
|
|
51
|
+
|
|
52
|
+
defineOptions({ inheritAttrs: false })
|
|
53
|
+
|
|
54
|
+
const props = defineProps<{
|
|
55
|
+
/** The topic's conversation id. */
|
|
56
|
+
conversationId?: string
|
|
57
|
+
apiBase?: string
|
|
58
|
+
siteBase?: string
|
|
59
|
+
lang?: string
|
|
60
|
+
signInHref?: string
|
|
61
|
+
/** JSON (or an object): the host's pages — `forumPath`, `categoryPath`, `topicPath`, `rulesPath`, `myCommunityPath`, … */
|
|
62
|
+
routes?: string | Record<string, unknown>
|
|
63
|
+
/** Head poll interval in ms (default 20 000; 0 = off). */
|
|
64
|
+
pollMs?: number
|
|
65
|
+
transport?: CommunityTransport
|
|
66
|
+
/** Live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime`, a property. Absent: the head poll. */
|
|
67
|
+
realtime?: TopicRealtime
|
|
68
|
+
}>()
|
|
69
|
+
|
|
70
|
+
const host = useHost()
|
|
71
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
72
|
+
const { t, text } = provideText(lang, FORUM_WORDS)
|
|
73
|
+
watch(
|
|
74
|
+
() => props.lang,
|
|
75
|
+
(v) => {
|
|
76
|
+
lang.value = pickLang(v, documentLang())
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const headingId = uid('bcm-topic')
|
|
81
|
+
const heading = ref<HTMLElement | null>(null)
|
|
82
|
+
const generation = ref(0)
|
|
83
|
+
const current = shallowRef<TopicController | null>(null)
|
|
84
|
+
const siteBase = computed(() => siteBaseFor(props.siteBase))
|
|
85
|
+
const client = computed(() => (siteBase.value === null ? null : communityClientFor(props.apiBase, props.transport, siteBase.value)))
|
|
86
|
+
|
|
87
|
+
function create(): TopicController | null {
|
|
88
|
+
const conversationId = props.conversationId?.trim()
|
|
89
|
+
if (!conversationId) {
|
|
90
|
+
warnOnce('conversation-id', '[blogmcp-community] <blogmcp-topic> needs a conversation-id')
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
if (!client.value) return null
|
|
94
|
+
return createTopicController({
|
|
95
|
+
client: client.value,
|
|
96
|
+
conversationId,
|
|
97
|
+
lang: lang.value,
|
|
98
|
+
pollMs: props.pollMs,
|
|
99
|
+
realtime: typeof props.realtime === 'function' ? props.realtime : null,
|
|
100
|
+
refusedAge: (host && refusedAges.get(host)) ?? null,
|
|
101
|
+
onAgeRefused: (minAge) => {
|
|
102
|
+
if (host) refusedAges.set(host, minAge)
|
|
103
|
+
},
|
|
104
|
+
onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const opened = reactive(new Set<number>())
|
|
109
|
+
const originals = reactive(new Set<number>())
|
|
110
|
+
const allReplies = reactive(new Set<number>())
|
|
111
|
+
const deleting = ref<number | null>(null)
|
|
112
|
+
|
|
113
|
+
const ctl = computed(() => current.value)
|
|
114
|
+
const thread = computed(() => current.value?.thread ?? null)
|
|
115
|
+
const state = computed(() => current.value?.thread.state ?? null)
|
|
116
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state.value?.me?.links, siteBase.value ?? ''))
|
|
117
|
+
|
|
118
|
+
/** A moderator's items of a message's menu (another member's live message in this topic). */
|
|
119
|
+
function extraMenu(m: DiscussionMessage): MenuItem[] {
|
|
120
|
+
if (!current.value?.canModerate(m)) return []
|
|
121
|
+
return [
|
|
122
|
+
{ id: 'mod-remove', label: t('moderation.remove'), danger: true },
|
|
123
|
+
{ id: 'mod-escalate', label: t('moderation.escalate') },
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
provideDiscussion({
|
|
128
|
+
get ctl() {
|
|
129
|
+
return current.value!.thread
|
|
130
|
+
},
|
|
131
|
+
routes,
|
|
132
|
+
opened,
|
|
133
|
+
originals,
|
|
134
|
+
allReplies,
|
|
135
|
+
focusHeading: () => heading.value,
|
|
136
|
+
deleteMessage: (seq) => {
|
|
137
|
+
deleting.value = seq
|
|
138
|
+
},
|
|
139
|
+
extraMenu,
|
|
140
|
+
onExtraMenu: (id, m) => {
|
|
141
|
+
if (id === 'mod-remove') current.value?.openDialog('remove', m.seq)
|
|
142
|
+
else if (id === 'mod-escalate') current.value?.openDialog('escalate', m.seq)
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
let observer: IntersectionObserver | null = null
|
|
147
|
+
const section = ref<HTMLElement | null>(null)
|
|
148
|
+
let anchored = false
|
|
149
|
+
|
|
150
|
+
/** A link to one message (`#bcm-msg-<seq>`, topicMessageLink): scrolled into view once the first page is in. */
|
|
151
|
+
async function scrollToAnchor(): Promise<void> {
|
|
152
|
+
if (anchored || typeof location === 'undefined') return
|
|
153
|
+
anchored = true
|
|
154
|
+
const seq = communityMessageSeqFromHash(location.hash)
|
|
155
|
+
const anchor = seq === null ? null : communityMessageAnchor(seq)
|
|
156
|
+
if (seq === null || anchor === null) return
|
|
157
|
+
const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
|
|
158
|
+
if (parent) allReplies.add(parent.seq)
|
|
159
|
+
await nextTick()
|
|
160
|
+
const row = section.value?.querySelector<HTMLElement>(`#${anchor}`)
|
|
161
|
+
if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function start(): void {
|
|
165
|
+
anchored = false
|
|
166
|
+
current.value?.dispose()
|
|
167
|
+
opened.clear()
|
|
168
|
+
originals.clear()
|
|
169
|
+
allReplies.clear()
|
|
170
|
+
deleting.value = null
|
|
171
|
+
current.value = create()
|
|
172
|
+
generation.value += 1
|
|
173
|
+
void current.value?.start()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
onMounted(() => {
|
|
177
|
+
start()
|
|
178
|
+
if (host && typeof IntersectionObserver !== 'undefined') {
|
|
179
|
+
observer = new IntersectionObserver((entries) => {
|
|
180
|
+
const e = entries[entries.length - 1]
|
|
181
|
+
if (e) current.value?.thread.setInView(e.isIntersecting)
|
|
182
|
+
}, { rootMargin: '200px' })
|
|
183
|
+
observer.observe(host)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
watch(() => [props.conversationId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
|
|
187
|
+
onBeforeUnmount(() => {
|
|
188
|
+
observer?.disconnect()
|
|
189
|
+
current.value?.dispose()
|
|
190
|
+
current.value = null
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
watch(
|
|
194
|
+
() => state.value?.phase,
|
|
195
|
+
(phase) => {
|
|
196
|
+
if (phase === 'ready') void scrollToAnchor()
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const topic = computed(() => state.value?.thread.topic ?? null)
|
|
201
|
+
const title = computed(() => (state.value ? topicTitleOf(state.value.thread) : null))
|
|
202
|
+
const titleText = computed(() => {
|
|
203
|
+
const ti = title.value
|
|
204
|
+
if (!ti) return ''
|
|
205
|
+
return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
|
|
206
|
+
})
|
|
207
|
+
const categoryHref = computed(() => (topic.value?.category ? categoryPath(routes.value.categoryPath, topic.value.category.slug) : null))
|
|
208
|
+
const mode = computed(() => thread.value?.mode() ?? 'pending')
|
|
209
|
+
/** The pause's sentence where the composer was: the module's, or the forum's own (never the article discussions'). */
|
|
210
|
+
const pausedText = computed(() => {
|
|
211
|
+
const notices = forumNoticesOf(state.value?.me ?? null)
|
|
212
|
+
return pausedKey(notices.paused ?? 'forum')
|
|
213
|
+
})
|
|
214
|
+
const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
|
|
215
|
+
watch(mode, (now, before) => {
|
|
216
|
+
if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
|
|
217
|
+
if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
|
|
218
|
+
})
|
|
219
|
+
const needKey = computed(() => {
|
|
220
|
+
const n = thread.value?.needs()
|
|
221
|
+
return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
/** The topic's moderator menu (close or reopen, pin or unpin, move). */
|
|
225
|
+
const moderatorItems = computed<MenuItem[]>(() => {
|
|
226
|
+
const c = current.value
|
|
227
|
+
const tp = topic.value
|
|
228
|
+
if (!c || !tp || !c.isModerator()) return []
|
|
229
|
+
const items: MenuItem[] = []
|
|
230
|
+
if (tp.state === 'open') items.push({ id: 'lock', label: t('moderation.lock') })
|
|
231
|
+
else if (tp.state === 'locked') items.push({ id: 'unlock', label: t('moderation.unlock') })
|
|
232
|
+
items.push(tp.pinned ? { id: 'unpin', label: t('moderation.unpin') } : { id: 'pin', label: t('moderation.pin') })
|
|
233
|
+
items.push({ id: 'move', label: t('moderation.move') })
|
|
234
|
+
return items
|
|
235
|
+
})
|
|
236
|
+
function onModerator(id: string): void {
|
|
237
|
+
const c = current.value
|
|
238
|
+
if (!c) return
|
|
239
|
+
if (id === 'lock' || id === 'unlock') void c.setState(id === 'lock' ? 'locked' : 'open')
|
|
240
|
+
else if (id === 'pin' || id === 'unpin') void c.setPinned(id === 'pin')
|
|
241
|
+
else if (id === 'move') c.openDialog('move')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sanctionText(r: DiscussionRestriction): string {
|
|
245
|
+
const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
|
|
246
|
+
if (r.kind === 'ban') return t('sanction.ban')
|
|
247
|
+
if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
|
|
248
|
+
return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const loadFailure = computed(() => {
|
|
252
|
+
const f = state.value?.failure
|
|
253
|
+
if (!f) return ''
|
|
254
|
+
const r = failureText(f, lang.value)
|
|
255
|
+
return t(r.key, r.params)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// One live region for the thread's and the topic's outcomes; the same outcome twice is announced twice.
|
|
259
|
+
const liveText = ref('')
|
|
260
|
+
function announceNow(next: string): void {
|
|
261
|
+
liveText.value = ''
|
|
262
|
+
void nextTick(() => {
|
|
263
|
+
liveText.value = next
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
watch(
|
|
267
|
+
() => state.value?.announceId,
|
|
268
|
+
() => announceNow(text(state.value?.announcement)),
|
|
269
|
+
)
|
|
270
|
+
watch(
|
|
271
|
+
() => current.value?.extras.announceId,
|
|
272
|
+
() => announceNow(text(current.value?.extras.announcement)),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async function confirmDelete(): Promise<void> {
|
|
276
|
+
const seq = deleting.value
|
|
277
|
+
if (seq === null || !thread.value) return
|
|
278
|
+
await thread.value.remove(seq)
|
|
279
|
+
deleting.value = null
|
|
280
|
+
}
|
|
281
|
+
</script>
|
|
282
|
+
|
|
283
|
+
<template>
|
|
284
|
+
<section v-if="ctl && thread && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-topic" :lang="lang" :aria-labelledby="headingId">
|
|
285
|
+
<nav class="bcm-row">
|
|
286
|
+
<a v-if="categoryHref && topic?.category" :href="categoryHref">{{ topic.category.name }}</a>
|
|
287
|
+
<a v-else :href="routes.forumPath">{{ t('forum.back') }}</a>
|
|
288
|
+
</nav>
|
|
289
|
+
|
|
290
|
+
<p v-if="state.phase === 'loading'" :id="headingId" class="bcm-muted" role="status">{{ t('topic.loading') }}</p>
|
|
291
|
+
|
|
292
|
+
<div v-else-if="state.phase === 'membersOnly' && client" :id="headingId">
|
|
293
|
+
<MembersOnly :failure="state.failure" :client="client" :lang="lang" :sign-in-href="signInHref" :rules-href="routes.rulesPath" @joined="thread.refresh()" />
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<p v-else-if="state.phase === 'notFound'" :id="headingId" class="bcm-note">{{ t('topic.notFound') }}</p>
|
|
297
|
+
|
|
298
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
299
|
+
<span :id="headingId">{{ loadFailure }}</span>
|
|
300
|
+
<button type="button" class="bcm-btn" @click="thread.refresh()">{{ t('common.retry') }}</button>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<template v-else>
|
|
304
|
+
<header class="bcm-head">
|
|
305
|
+
<h2 :id="headingId" ref="heading" tabindex="-1" :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</h2>
|
|
306
|
+
<span v-if="topic?.pinned" class="bcm-badge">{{ t('topic.pinned') }}</span>
|
|
307
|
+
<span v-if="topic?.state === 'locked'" class="bcm-badge">{{ t('topic.locked') }}</span>
|
|
308
|
+
<span v-else-if="topic?.state === 'archived'" class="bcm-badge">{{ t('topic.archived') }}</span>
|
|
309
|
+
<span class="bcm-count">{{ t('topic.count', { count: state.thread.count }) }}</span>
|
|
310
|
+
<span class="bcm-spacer" />
|
|
311
|
+
<FollowToggle v-if="ctl.follow && state.thread.viewer.memberId !== null" :ctl="ctl.follow" />
|
|
312
|
+
<MessageMenu v-if="moderatorItems.length" :items="moderatorItems" :label="t('moderation.title')" @select="onModerator" />
|
|
313
|
+
</header>
|
|
314
|
+
|
|
315
|
+
<ForumNotices :me="state.me" />
|
|
316
|
+
|
|
317
|
+
<div v-if="state.newMessages" class="bcm-banner" role="status">
|
|
318
|
+
<span>{{ t('discussion.newMessages') }}</span>
|
|
319
|
+
<button type="button" class="bcm-btn" :disabled="state.refreshing" @click="thread.refresh()">{{ t('discussion.refresh') }}</button>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
|
|
323
|
+
|
|
324
|
+
<ol class="bcm-thread">
|
|
325
|
+
<MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
|
|
326
|
+
</ol>
|
|
327
|
+
<p v-if="state.thread.messages.length <= 1" class="bcm-muted">{{ t('discussion.empty') }}</p>
|
|
328
|
+
|
|
329
|
+
<div v-if="state.thread.nextCursor !== null" class="bcm-row">
|
|
330
|
+
<button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="thread.loadMore()">{{ t('discussion.loadMore') }}</button>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div class="bcm-section">
|
|
334
|
+
<p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
|
|
335
|
+
<MessageComposer
|
|
336
|
+
v-if="mode === 'write'"
|
|
337
|
+
ref="composer"
|
|
338
|
+
kind="new"
|
|
339
|
+
:draft="state.composer"
|
|
340
|
+
:wait="thread.waitLeft(state.composer)"
|
|
341
|
+
:rules-href="routes.rulesPath"
|
|
342
|
+
:mentions-max="thread.mentionCap()"
|
|
343
|
+
@submit="thread.submit()"
|
|
344
|
+
@confirm="(code: string, checked: boolean) => thread && thread.setConfirmed(state!.composer, code, checked)"
|
|
345
|
+
@confirmed="thread.sendConfirmed(state.composer)"
|
|
346
|
+
/>
|
|
347
|
+
<p v-else-if="mode === 'locked'" class="bcm-note">{{ t('error.topicLocked') }}</p>
|
|
348
|
+
<p v-else-if="mode === 'readOnly'" class="bcm-note">{{ t('error.readOnly') }}</p>
|
|
349
|
+
<template v-else-if="mode === 'onboarding'">
|
|
350
|
+
<OnboardingSteps v-if="thread.onboarding.value" :model="thread.onboarding.value" :rules-href="routes.rulesPath" @cancel="thread.cancelOnboarding()" />
|
|
351
|
+
<div v-else class="bcm-row">
|
|
352
|
+
<p>{{ t(needKey) }}</p>
|
|
353
|
+
<button type="button" class="bcm-btn bcm-primary" @click="thread.beginOnboarding()">{{ t('onboarding.continue') }}</button>
|
|
354
|
+
</div>
|
|
355
|
+
</template>
|
|
356
|
+
<p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
|
|
357
|
+
<div v-else-if="mode === 'sanction'" class="bcm-section">
|
|
358
|
+
<p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
|
|
359
|
+
{{ sanctionText(r) }}
|
|
360
|
+
<a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
|
|
361
|
+
</p>
|
|
362
|
+
</div>
|
|
363
|
+
<p v-else-if="mode === 'braked' && pausedText" class="bcm-note" role="status">{{ t(pausedText) }}</p>
|
|
364
|
+
<p v-else-if="mode === 'restricted'" class="bcm-note">
|
|
365
|
+
{{ t('error.restricted') }}
|
|
366
|
+
<a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
</template>
|
|
370
|
+
|
|
371
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
372
|
+
|
|
373
|
+
<ReportDialog
|
|
374
|
+
v-if="state.report"
|
|
375
|
+
:report="state.report"
|
|
376
|
+
:my-community-href="routes.myCommunityPath"
|
|
377
|
+
:rules-href="routes.rulesPath"
|
|
378
|
+
:fallback-focus="() => heading"
|
|
379
|
+
@submit="thread.submitReport()"
|
|
380
|
+
@close="thread.closeReport()"
|
|
381
|
+
/>
|
|
382
|
+
<ConfirmDialog
|
|
383
|
+
v-if="deleting !== null"
|
|
384
|
+
title="discussion.deleteTitle"
|
|
385
|
+
explain="discussion.deleteExplain"
|
|
386
|
+
confirm="discussion.deleteConfirm"
|
|
387
|
+
danger
|
|
388
|
+
:busy="!!state.busy[`delete:${deleting}`]"
|
|
389
|
+
:fallback-focus="() => heading"
|
|
390
|
+
@confirm="confirmDelete()"
|
|
391
|
+
@cancel="deleting = null"
|
|
392
|
+
/>
|
|
393
|
+
<ModeratorDialog v-if="ctl.extras.dialog" :ctl="ctl" :fallback-focus="() => heading" @close="ctl.closeDialog()" />
|
|
394
|
+
</section>
|
|
395
|
+
</template>
|
package/src/ui/context.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `communityMessage` and nothing else), ids unique to the element, and the dialog's focus handling.
|
|
4
4
|
*/
|
|
5
5
|
import { inject, nextTick, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
|
|
6
|
-
import { communityMessage } from '@blogmcp/sdk/community'
|
|
6
|
+
import { communityMessage, type CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
7
7
|
import type { Lang, TextRef, Translate } from '../core/i18n'
|
|
8
8
|
|
|
9
9
|
export interface TextContext {
|
|
@@ -15,9 +15,14 @@ export interface TextContext {
|
|
|
15
15
|
|
|
16
16
|
const TEXT: InjectionKey<TextContext> = Symbol('bcm-text')
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The element's texts. `words` (M3) maps a key the shared components name to another key of the catalogue — a topic's
|
|
20
|
+
* thread reads the discussion's components with the forum's words (`composer.label` → `forumText.composer.label`, a
|
|
21
|
+
* post, not a comment) — and a plural key maps by its name without the suffix.
|
|
22
|
+
*/
|
|
23
|
+
export function provideText(lang: Ref<Lang>, words?: Readonly<Partial<Record<CommunityMessageKey, CommunityMessageKey>>>): TextContext {
|
|
19
24
|
// Reads lang.value on every call, so a view that renders a text re-renders when the language changes.
|
|
20
|
-
const t: Translate = (key, params) => communityMessage(lang.value, key, params)
|
|
25
|
+
const t: Translate = (key, params) => communityMessage(lang.value, words?.[key] ?? key, params)
|
|
21
26
|
const ctx: TextContext = { lang, t, text: (ref) => (ref ? t(ref.key, ref.params) : '') }
|
|
22
27
|
provide(TEXT, ctx)
|
|
23
28
|
return ctx
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* What the discussion's components share: the controller, the host's routes, what the reader opened — M3: under a
|
|
3
|
+
* topic too (`<blogmcp-topic>` provides its thread's controller, and the moderator's items of a message's menu).
|
|
4
|
+
*/
|
|
2
5
|
import { inject, provide, type ComputedRef, type InjectionKey } from 'vue'
|
|
3
|
-
import type {
|
|
6
|
+
import type { DiscussionMessage } from '@blogmcp/sdk/community'
|
|
7
|
+
import type { ThreadController } from '../core/discussion'
|
|
4
8
|
import type { routesWithDefaults } from '../core/attrs'
|
|
9
|
+
import type { MenuItem } from './types'
|
|
5
10
|
|
|
6
11
|
export interface DiscussionContext {
|
|
7
|
-
ctl:
|
|
12
|
+
ctl: ThreadController
|
|
8
13
|
routes: ComputedRef<ReturnType<typeof routesWithDefaults>>
|
|
9
14
|
/** Messages of a blocked author the reader opened anyway. */
|
|
10
15
|
opened: Set<number>
|
|
@@ -15,6 +20,9 @@ export interface DiscussionContext {
|
|
|
15
20
|
/** Where the focus goes when the element it came from is gone (a deleted message). */
|
|
16
21
|
focusHeading(): HTMLElement | null
|
|
17
22
|
deleteMessage(seq: number): void
|
|
23
|
+
/** M3: more items of a message's menu (a category moderator's), and what picking one does. */
|
|
24
|
+
extraMenu?(m: DiscussionMessage): MenuItem[]
|
|
25
|
+
onExtraMenu?(id: string, m: DiscussionMessage): void
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
const KEY: InjectionKey<DiscussionContext> = Symbol('bcm-discussion')
|
package/src/ui/styles.css
CHANGED
|
@@ -257,3 +257,17 @@ span.bcm-react { cursor: default; }
|
|
|
257
257
|
.bcm-dialog[open] { animation: bcm-in 140ms ease-out; }
|
|
258
258
|
@keyframes bcm-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
259
259
|
}
|
|
260
|
+
|
|
261
|
+
/* ── M3: the forum (its index, a category, a topic, search, the follow button, the moderator's queue) ── */
|
|
262
|
+
:host(blogmcp-follow-button) { display: inline-block; }
|
|
263
|
+
.bcm-follow-host { display: inline-flex; }
|
|
264
|
+
.bcm-follow { display: inline-flex; align-items: center; gap: 6px; }
|
|
265
|
+
/* Following: a thicker border and bold, not colour alone. */
|
|
266
|
+
.bcm-follow .bcm-btn[aria-pressed='true'] { border: 2px solid var(--bcm-accent); font-weight: 650; }
|
|
267
|
+
.bcm-grow { flex: 1; min-width: 0; }
|
|
268
|
+
.bcm-select { width: auto; max-width: 100%; }
|
|
269
|
+
.bcm-category { gap: 8px; }
|
|
270
|
+
.bcm-topics { list-style: none; display: flex; flex-direction: column; }
|
|
271
|
+
.bcm-topic-row { border-bottom: 1px solid var(--bcm-line); padding: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
|
272
|
+
.bcm-topic-row:last-child { border-bottom: 0; }
|
|
273
|
+
.bcm-topic-title { font-weight: 650; }
|
package/src/vue.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity
|
|
3
|
-
* `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>`
|
|
2
|
+
* `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`, (M2) `<KbNotificationCentre>`, `<KbBadge>`,
|
|
3
|
+
* `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` and (M3) `<KbForum>`, `<KbForumCategory>`, `<KbTopic>`,
|
|
4
|
+
* `<KbForumSearch>`, `<KbFollowButton>`, `<KbForumModeration>` for Vue / Nuxt: each mounts its element on the
|
|
4
5
|
* client, after hydration, and removes it on unmount. Each renders one empty `<div>` as the mount point —
|
|
5
6
|
* nothing else, on the server or the client — so the element sits where the component does and the page's
|
|
6
7
|
* markup is otherwise the site's alone.
|
|
@@ -13,15 +14,21 @@
|
|
|
13
14
|
import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
|
14
15
|
import type { CommunityTransport } from '@blogmcp/sdk/community'
|
|
15
16
|
import type { CommunityRoutes } from './core/attrs'
|
|
16
|
-
import type { DiscussionRealtime } from './core/realtime'
|
|
17
|
+
import type { DiscussionRealtime, TopicRealtime } from './core/realtime'
|
|
17
18
|
import type {
|
|
18
19
|
MountBadgeOptions,
|
|
19
20
|
MountDiscussionOptions,
|
|
21
|
+
MountFollowButtonOptions,
|
|
22
|
+
MountForumCategoryOptions,
|
|
23
|
+
MountForumModerationOptions,
|
|
24
|
+
MountForumOptions,
|
|
25
|
+
MountForumSearchOptions,
|
|
20
26
|
MountMemberSettingsOptions,
|
|
21
27
|
MountMyCommunityOptions,
|
|
22
28
|
MountNoticeFormOptions,
|
|
23
29
|
MountNotificationCentreOptions,
|
|
24
30
|
MountProfileOptions,
|
|
31
|
+
MountTopicOptions,
|
|
25
32
|
MountWelcomeOptions,
|
|
26
33
|
} from './mount'
|
|
27
34
|
|
|
@@ -228,6 +235,134 @@ export const KbWelcome = defineComponent({
|
|
|
228
235
|
},
|
|
229
236
|
})
|
|
230
237
|
|
|
238
|
+
// ───────────────────────── M3: the forum (m3-contracts.md §5.3) ─────────────────────────
|
|
239
|
+
|
|
240
|
+
export const KbForum = defineComponent({
|
|
241
|
+
name: 'KbForum',
|
|
242
|
+
props: {
|
|
243
|
+
apiBase: String,
|
|
244
|
+
siteBase: String,
|
|
245
|
+
lang: String,
|
|
246
|
+
signInHref: String,
|
|
247
|
+
routes: Object as PropType<CommunityRoutes>,
|
|
248
|
+
nonce: String,
|
|
249
|
+
transport: Function as PropType<CommunityTransport>,
|
|
250
|
+
},
|
|
251
|
+
setup(props) {
|
|
252
|
+
return useMount<MountForumOptions>(
|
|
253
|
+
'forum',
|
|
254
|
+
() => defined({ ...props }) as MountForumOptions,
|
|
255
|
+
async () => (await import('./mount')).mountForum as Mounter<MountForumOptions>,
|
|
256
|
+
)
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
export const KbForumCategory = defineComponent({
|
|
261
|
+
name: 'KbForumCategory',
|
|
262
|
+
props: {
|
|
263
|
+
slug: { type: String, required: true },
|
|
264
|
+
apiBase: String,
|
|
265
|
+
siteBase: String,
|
|
266
|
+
lang: String,
|
|
267
|
+
signInHref: String,
|
|
268
|
+
routes: Object as PropType<CommunityRoutes>,
|
|
269
|
+
nonce: String,
|
|
270
|
+
transport: Function as PropType<CommunityTransport>,
|
|
271
|
+
},
|
|
272
|
+
setup(props) {
|
|
273
|
+
return useMount<MountForumCategoryOptions>(
|
|
274
|
+
'forum-category',
|
|
275
|
+
() => defined({ ...props }) as MountForumCategoryOptions,
|
|
276
|
+
async () => (await import('./mount')).mountForumCategory as Mounter<MountForumCategoryOptions>,
|
|
277
|
+
)
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
export const KbTopic = defineComponent({
|
|
282
|
+
name: 'KbTopic',
|
|
283
|
+
props: {
|
|
284
|
+
conversationId: { type: String, required: true },
|
|
285
|
+
apiBase: String,
|
|
286
|
+
siteBase: String,
|
|
287
|
+
lang: String,
|
|
288
|
+
signInHref: String,
|
|
289
|
+
routes: Object as PropType<CommunityRoutes>,
|
|
290
|
+
pollMs: Number,
|
|
291
|
+
nonce: String,
|
|
292
|
+
transport: Function as PropType<CommunityTransport>,
|
|
293
|
+
/** `createRealtimeSession` of `@blogmcp/sdk/community/realtime`. */
|
|
294
|
+
realtime: Function as PropType<TopicRealtime>,
|
|
295
|
+
},
|
|
296
|
+
setup(props) {
|
|
297
|
+
return useMount<MountTopicOptions>(
|
|
298
|
+
'topic',
|
|
299
|
+
() => defined({ ...props }) as MountTopicOptions,
|
|
300
|
+
async () => (await import('./mount')).mountTopic as Mounter<MountTopicOptions>,
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
export const KbForumSearch = defineComponent({
|
|
306
|
+
name: 'KbForumSearch',
|
|
307
|
+
props: {
|
|
308
|
+
apiBase: String,
|
|
309
|
+
siteBase: String,
|
|
310
|
+
lang: String,
|
|
311
|
+
signInHref: String,
|
|
312
|
+
routes: Object as PropType<CommunityRoutes>,
|
|
313
|
+
nonce: String,
|
|
314
|
+
transport: Function as PropType<CommunityTransport>,
|
|
315
|
+
},
|
|
316
|
+
setup(props) {
|
|
317
|
+
return useMount<MountForumSearchOptions>(
|
|
318
|
+
'forum-search',
|
|
319
|
+
() => defined({ ...props }) as MountForumSearchOptions,
|
|
320
|
+
async () => (await import('./mount')).mountForumSearch as Mounter<MountForumSearchOptions>,
|
|
321
|
+
)
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
export const KbFollowButton = defineComponent({
|
|
326
|
+
name: 'KbFollowButton',
|
|
327
|
+
props: {
|
|
328
|
+
targetKind: { type: String as PropType<'conversation' | 'category'>, required: true },
|
|
329
|
+
targetId: { type: String, required: true },
|
|
330
|
+
following: { type: Boolean, default: undefined },
|
|
331
|
+
apiBase: String,
|
|
332
|
+
siteBase: String,
|
|
333
|
+
lang: String,
|
|
334
|
+
nonce: String,
|
|
335
|
+
transport: Function as PropType<CommunityTransport>,
|
|
336
|
+
},
|
|
337
|
+
setup(props) {
|
|
338
|
+
return useMount<MountFollowButtonOptions>(
|
|
339
|
+
'follow-button',
|
|
340
|
+
() => defined({ ...props }) as MountFollowButtonOptions,
|
|
341
|
+
async () => (await import('./mount')).mountFollowButton as Mounter<MountFollowButtonOptions>,
|
|
342
|
+
)
|
|
343
|
+
},
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
export const KbForumModeration = defineComponent({
|
|
347
|
+
name: 'KbForumModeration',
|
|
348
|
+
props: {
|
|
349
|
+
categoryId: String,
|
|
350
|
+
apiBase: String,
|
|
351
|
+
siteBase: String,
|
|
352
|
+
lang: String,
|
|
353
|
+
routes: Object as PropType<CommunityRoutes>,
|
|
354
|
+
nonce: String,
|
|
355
|
+
transport: Function as PropType<CommunityTransport>,
|
|
356
|
+
},
|
|
357
|
+
setup(props) {
|
|
358
|
+
return useMount<MountForumModerationOptions>(
|
|
359
|
+
'forum-moderation',
|
|
360
|
+
() => defined({ ...props }) as MountForumModerationOptions,
|
|
361
|
+
async () => (await import('./mount')).mountForumModeration as Mounter<MountForumModerationOptions>,
|
|
362
|
+
)
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
|
|
231
366
|
export type {
|
|
232
367
|
MountDiscussionOptions,
|
|
233
368
|
MountNoticeFormOptions,
|
|
@@ -237,6 +372,13 @@ export type {
|
|
|
237
372
|
MountMemberSettingsOptions,
|
|
238
373
|
MountProfileOptions,
|
|
239
374
|
MountWelcomeOptions,
|
|
375
|
+
MountForumOptions,
|
|
376
|
+
MountForumCategoryOptions,
|
|
377
|
+
MountTopicOptions,
|
|
378
|
+
MountForumSearchOptions,
|
|
379
|
+
MountFollowButtonOptions,
|
|
380
|
+
MountForumModerationOptions,
|
|
240
381
|
CommunityRoutes,
|
|
241
382
|
DiscussionRealtime,
|
|
383
|
+
TopicRealtime,
|
|
242
384
|
}
|