@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,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>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-welcome>`: the welcome page after joining (m2-contracts.md §3.10; the storefront's `/komunita/vitejte`) —
|
|
4
|
+
* the tenant's rules in summary (as the onboarding shows them, with the link to the rules page) and three links: the
|
|
5
|
+
* discussions, the settings, the member's own profile. A visitor gets the sign-in link in the profile's place.
|
|
6
|
+
*/
|
|
7
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
8
|
+
import { profilePath, signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
9
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
10
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
11
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
12
|
+
import { createWelcome, type WelcomeController } from '../core/profile'
|
|
13
|
+
import { provideText, uid } from './context'
|
|
14
|
+
|
|
15
|
+
defineOptions({ inheritAttrs: false })
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
apiBase?: string
|
|
19
|
+
siteBase?: string
|
|
20
|
+
lang?: string
|
|
21
|
+
/** JSON (or an object): `rulesPath`, `discussionsPath`, `settingsPath`, `profilePath`. */
|
|
22
|
+
routes?: string | Record<string, unknown>
|
|
23
|
+
signInHref?: string
|
|
24
|
+
transport?: CommunityTransport
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
28
|
+
const { t } = provideText(lang)
|
|
29
|
+
const id = uid('bcm-welcome')
|
|
30
|
+
|
|
31
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
32
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
33
|
+
const ctl: WelcomeController | null = client ? createWelcome({ client, lang: lang.value }) : null
|
|
34
|
+
const state = ctl?.state ?? null
|
|
35
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
|
|
36
|
+
const ownProfile = computed(() => (state?.handle ? profilePath(routes.value.profilePath, state.handle) : null))
|
|
37
|
+
|
|
38
|
+
onMounted(() => void ctl?.load())
|
|
39
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
40
|
+
|
|
41
|
+
function signInHref(): string | null {
|
|
42
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<section v-if="ctl && state" class="bcm bcm-welcome" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
48
|
+
<h2 :id="`${id}-title`">{{ t('welcome.title') }}</h2>
|
|
49
|
+
<p>{{ t('welcome.lede') }}</p>
|
|
50
|
+
|
|
51
|
+
<p v-if="state.phase !== 'ready'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
52
|
+
<template v-else>
|
|
53
|
+
<section class="bcm-section" :aria-labelledby="`${id}-rules`">
|
|
54
|
+
<h3 :id="`${id}-rules`">{{ t('welcome.rules') }}</h3>
|
|
55
|
+
<ol v-if="state.rules.length" class="bcm-rules">
|
|
56
|
+
<li v-for="rule in state.rules" :key="rule.id">
|
|
57
|
+
<strong>{{ rule.title }}</strong>
|
|
58
|
+
<span v-if="rule.summary" class="bcm-hint">{{ rule.summary }}</span>
|
|
59
|
+
</li>
|
|
60
|
+
</ol>
|
|
61
|
+
<p><a :href="routes.rulesPath">{{ t('onboarding.rulesLink') }}</a></p>
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<ul class="bcm-links">
|
|
65
|
+
<li><a class="bcm-btn" :href="routes.discussionsPath">{{ t('welcome.discussions') }}</a></li>
|
|
66
|
+
<li><a class="bcm-btn" :href="routes.settingsPath">{{ t('welcome.settings') }}</a></li>
|
|
67
|
+
<li v-if="ownProfile"><a class="bcm-btn" :href="ownProfile">{{ t('welcome.profile') }}</a></li>
|
|
68
|
+
<li v-else-if="state.visitor && signInHref()"><a class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a></li>
|
|
69
|
+
</ul>
|
|
70
|
+
</template>
|
|
71
|
+
</section>
|
|
72
|
+
</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
|
@@ -220,6 +220,22 @@ span.bcm-react { cursor: default; }
|
|
|
220
220
|
.bcm-receipt { display: flex; flex-direction: column; gap: 8px; }
|
|
221
221
|
.bcm-turnstile { min-height: 0; }
|
|
222
222
|
|
|
223
|
+
/* ── M2: mentions, the badge, the centre, settings, profiles, the welcome page ── */
|
|
224
|
+
.bcm-mention { color: var(--bcm-accent); font-weight: 600; text-decoration: none; }
|
|
225
|
+
.bcm-mention:hover { text-decoration: underline; }
|
|
226
|
+
:host(blogmcp-badge) { display: inline-block; }
|
|
227
|
+
.bcm-badge-link {
|
|
228
|
+
display: inline-flex; align-items: center; justify-content: center; min-width: 1.5em; height: 1.5em; padding: 0 6px;
|
|
229
|
+
border-radius: 999px; background: var(--bcm-accent); color: var(--bcm-accent-fg); font-size: 0.8125em; font-weight: 700;
|
|
230
|
+
line-height: 1; text-decoration: none; font-variant-numeric: tabular-nums;
|
|
231
|
+
}
|
|
232
|
+
.bcm-switches { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
|
233
|
+
.bcm-switches li { display: flex; flex-direction: column; gap: 2px; }
|
|
234
|
+
.bcm-profile-head { display: flex; align-items: center; gap: 12px; }
|
|
235
|
+
.bcm-profile-head .bcm-avatar { width: 48px; height: 48px; font-size: 26px; }
|
|
236
|
+
.bcm-links { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
|
237
|
+
.bcm-url { overflow-wrap: anywhere; font-family: ui-monospace, monospace; font-size: 0.875em; }
|
|
238
|
+
|
|
223
239
|
/* ── dialogs (native <dialog>: the top layer, Esc, focus kept inside) ── */
|
|
224
240
|
.bcm-dialog {
|
|
225
241
|
border: 1px solid var(--bcm-line); border-radius: 14px; padding: 0;
|
|
@@ -241,3 +257,17 @@ span.bcm-react { cursor: default; }
|
|
|
241
257
|
.bcm-dialog[open] { animation: bcm-in 140ms ease-out; }
|
|
242
258
|
@keyframes bcm-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
243
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; }
|