@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,309 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-forum-moderation>` (m3-contracts.md §3.4, §4.2, §4.5, §5.3; D-M3-6): a category moderator's queue — the
|
|
4
|
+
* cases of category `rules` in their categories (a member's report, the filters' hold, the team's) and the messages
|
|
5
|
+
* waiting in pre-moderation. A case opens to its package as written (text only: no reporter, no note, no contact) and
|
|
6
|
+
* the decision through the engine — restore, hide or remove, with the facts and the rule it rests on —, its statement
|
|
7
|
+
* of reasons previewed first and decided after; an illegal line's matter passes to the team. A pre-moderated message
|
|
8
|
+
* is published (no statement: nothing was restricted) or refused (a rule and the facts: the statement comes back) —
|
|
9
|
+
* M3 FX (R3): of the text shown here (kb-api answers it with the queue, rendered as text): an edit by its author
|
|
10
|
+
* meanwhile shows the new text and asks again (`moderation.textChanged`). M3 FX2 (S12): a package item of an opening
|
|
11
|
+
* post whose opening post was deleted reads „Smazané téma" (kb-api's `openingState`, topicTitleOf).
|
|
12
|
+
* Renders nothing for a member who moderates nothing now (and for anyone the forum is not open to).
|
|
13
|
+
*/
|
|
14
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
15
|
+
import {
|
|
16
|
+
ESCALATION_CATEGORIES,
|
|
17
|
+
MODERATION_RESTRICTIONS,
|
|
18
|
+
hasCommunityMessage,
|
|
19
|
+
topicMessageLink,
|
|
20
|
+
topicTitleOf,
|
|
21
|
+
type CaseCategory,
|
|
22
|
+
type CommunityMessageKey,
|
|
23
|
+
type CommunityTransport,
|
|
24
|
+
type ModerationCaseItem,
|
|
25
|
+
type ModerationItem,
|
|
26
|
+
type ModerationRestriction,
|
|
27
|
+
} from '@blogmcp/sdk/community'
|
|
28
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
29
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
30
|
+
import { formatDate } from '../core/format'
|
|
31
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
32
|
+
import { createModeration, type ModerationController } from '../core/moderation'
|
|
33
|
+
import { failureText } from '../core/outcomes'
|
|
34
|
+
import { provideText, uid } from './context'
|
|
35
|
+
|
|
36
|
+
defineOptions({ inheritAttrs: false })
|
|
37
|
+
|
|
38
|
+
const props = defineProps<{
|
|
39
|
+
/** Narrow the queue to one of the moderator's categories (its id). */
|
|
40
|
+
categoryId?: string
|
|
41
|
+
apiBase?: string
|
|
42
|
+
siteBase?: string
|
|
43
|
+
lang?: string
|
|
44
|
+
routes?: string | Record<string, unknown>
|
|
45
|
+
transport?: CommunityTransport
|
|
46
|
+
}>()
|
|
47
|
+
|
|
48
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
49
|
+
const { t, text } = provideText(lang)
|
|
50
|
+
const id = uid('bcm-moderation')
|
|
51
|
+
watch(
|
|
52
|
+
() => props.lang,
|
|
53
|
+
(v) => {
|
|
54
|
+
lang.value = pickLang(v, documentLang())
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
59
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
60
|
+
const ctl: ModerationController | null = client ? createModeration({ client, lang: lang.value, categoryId: props.categoryId ?? null }) : null
|
|
61
|
+
const state = ctl?.state ?? null
|
|
62
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
|
|
63
|
+
|
|
64
|
+
/** An opening post's package item without its title: „Smazané téma" for a deleted opening post, the withheld wording otherwise (topicTitleOf). */
|
|
65
|
+
function missingTitle(it: ModerationCaseItem): string {
|
|
66
|
+
const ti = topicTitleOf({ title: it.title ?? null, openingState: it.openingState })
|
|
67
|
+
return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMounted(() => void ctl?.load())
|
|
71
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
72
|
+
|
|
73
|
+
const RESTRICTIONS: readonly ModerationRestriction[] = MODERATION_RESTRICTIONS
|
|
74
|
+
const restrictionKey = (r: ModerationRestriction): CommunityMessageKey => `restriction.${r}`
|
|
75
|
+
const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
|
|
76
|
+
const caseCategoryText = (c: string | null): string => {
|
|
77
|
+
const key = c === null ? null : `category.${c}`
|
|
78
|
+
return key !== null && hasCommunityMessage(key) ? t(key) : ''
|
|
79
|
+
}
|
|
80
|
+
const sourceText = (item: ModerationItem): string => {
|
|
81
|
+
const key = item.source === null ? null : `moderation.source.${item.source}`
|
|
82
|
+
return key !== null && hasCommunityMessage(key) ? t(key) : ''
|
|
83
|
+
}
|
|
84
|
+
const categoryName = (categoryId: string): string => state?.categories.find((c) => c.id.toLowerCase() === categoryId.toLowerCase())?.name ?? ''
|
|
85
|
+
const ruleTitle = (ruleId: string | null): string => (ruleId === null ? '' : (state?.rules?.find((r) => r.id === ruleId)?.title ?? ruleId))
|
|
86
|
+
const itemHref = (item: ModerationItem): string | null => topicMessageLink(routes.value.topicPath, item.conversationId, item.seq)
|
|
87
|
+
const failureOf = (f: { code: Parameters<typeof failureText>[0]['code'] } | null | undefined): string => {
|
|
88
|
+
if (!f) return ''
|
|
89
|
+
const r = failureText(f, lang.value)
|
|
90
|
+
return t(r.key, r.params)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const open = computed(() => state?.open ?? null)
|
|
94
|
+
const review = computed(() => state?.review ?? null)
|
|
95
|
+
/** The rule select shows when the decision needs one chosen: kb-api asked, or the case names none for a restriction. */
|
|
96
|
+
const ruleNeeded = computed(() => {
|
|
97
|
+
const c = open.value
|
|
98
|
+
return !!c && c.restriction !== '' && c.restriction !== 'no_action' && (c.needsRule || c.kase?.ruleId === null)
|
|
99
|
+
})
|
|
100
|
+
// A changed decision is a new one: its statement is previewed again before anything is decided.
|
|
101
|
+
watch(
|
|
102
|
+
() => [open.value?.restriction, open.value?.facts, open.value?.ground] as const,
|
|
103
|
+
() => {
|
|
104
|
+
const c = open.value
|
|
105
|
+
if (c && !c.decided) c.preview = null
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const liveText = ref('')
|
|
110
|
+
watch(
|
|
111
|
+
() => state?.announceId,
|
|
112
|
+
() => {
|
|
113
|
+
liveText.value = ''
|
|
114
|
+
const next = text(state?.announcement)
|
|
115
|
+
void nextTick(() => {
|
|
116
|
+
liveText.value = next
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<template>
|
|
123
|
+
<section v-if="ctl && state && state.phase !== 'none' && state.phase !== 'hidden' && state.phase !== 'membersOnly'" class="bcm bcm-forum-moderation" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
124
|
+
<header class="bcm-head">
|
|
125
|
+
<h2 :id="`${id}-title`">{{ t('moderation.title') }}</h2>
|
|
126
|
+
</header>
|
|
127
|
+
|
|
128
|
+
<p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('moderation.loading') }}</p>
|
|
129
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
130
|
+
<span>{{ failureOf(state.failure) }}</span>
|
|
131
|
+
<button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<template v-else>
|
|
135
|
+
<div v-if="state.categories.length > 1" class="bcm-row">
|
|
136
|
+
<label :for="`${id}-category`">{{ t('search.category') }}</label>
|
|
137
|
+
<select :id="`${id}-category`" class="bcm-input bcm-select" :value="state.categoryId" @change="ctl.setCategory(($event.target as HTMLSelectElement).value)">
|
|
138
|
+
<option value="">{{ t('forum.allCategories') }}</option>
|
|
139
|
+
<option v-for="c in state.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
|
140
|
+
</select>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<p v-if="state.items.length === 0" class="bcm-muted">{{ t('moderation.empty') }}</p>
|
|
144
|
+
<ul v-else class="bcm-list">
|
|
145
|
+
<li v-for="item in state.items" :key="`${item.kind}:${item.caseId ?? ''}:${item.conversationId}:${item.seq}`">
|
|
146
|
+
<p class="bcm-row">
|
|
147
|
+
<span class="bcm-badge">{{ item.kind === 'premod' ? t('moderation.premod') : sourceText(item) }}</span>
|
|
148
|
+
<strong v-if="item.kind === 'case' && caseCategoryText(item.caseCategory)">{{ caseCategoryText(item.caseCategory) }}</strong>
|
|
149
|
+
<span v-if="categoryName(item.categoryId)" class="bcm-muted">{{ categoryName(item.categoryId) }}</span>
|
|
150
|
+
</p>
|
|
151
|
+
<p v-if="item.caseStatus === 'decided'" class="bcm-hint">{{ t('moderation.heldBySystem') }}</p>
|
|
152
|
+
<!-- R3: a pre-moderated message's text (and an opening post's title), as written — rendered as text. -->
|
|
153
|
+
<template v-if="item.kind === 'premod'">
|
|
154
|
+
<p v-if="item.title" class="bcm-topic-title">{{ item.title }}</p>
|
|
155
|
+
<p v-if="item.body !== null" class="bcm-text">{{ item.body }}</p>
|
|
156
|
+
</template>
|
|
157
|
+
<p v-if="item.ruleId" class="bcm-muted">{{ t('statement.contractualGround') }}: {{ ruleTitle(item.ruleId) }}</p>
|
|
158
|
+
<p class="bcm-row bcm-muted">
|
|
159
|
+
<time :datetime="item.createdAt">{{ formatDate(lang, item.createdAt) }}</time>
|
|
160
|
+
<span v-if="item.deadlineAt">{{ t('moderation.deadline', { date: formatDate(lang, item.deadlineAt) }) }}</span>
|
|
161
|
+
</p>
|
|
162
|
+
<div class="bcm-row">
|
|
163
|
+
<a v-if="itemHref(item)" :href="itemHref(item) ?? undefined">{{ t('moderation.openTopic') }}</a>
|
|
164
|
+
<span class="bcm-spacer" />
|
|
165
|
+
<template v-if="item.kind === 'case'">
|
|
166
|
+
<button type="button" class="bcm-btn bcm-small" @click="ctl.openCase(item)">{{ t('moderation.open') }}</button>
|
|
167
|
+
</template>
|
|
168
|
+
<template v-else>
|
|
169
|
+
<button type="button" class="bcm-btn bcm-small" @click="ctl.openReview(item, 'approve')">{{ t('moderation.approve') }}</button>
|
|
170
|
+
<button type="button" class="bcm-btn bcm-small bcm-danger" @click="ctl.openReview(item, 'reject')">{{ t('moderation.reject') }}</button>
|
|
171
|
+
</template>
|
|
172
|
+
</div>
|
|
173
|
+
</li>
|
|
174
|
+
</ul>
|
|
175
|
+
<div v-if="state.nextCursor !== null" class="bcm-row">
|
|
176
|
+
<button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- A case: its package as written, then the decision through the engine with its statement previewed. -->
|
|
180
|
+
<section v-if="open" class="bcm-card" :aria-labelledby="`${id}-case`">
|
|
181
|
+
<h3 :id="`${id}-case`">{{ t('moderation.package') }}</h3>
|
|
182
|
+
<p v-if="open.loading" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
183
|
+
<ol v-else-if="open.kase" class="bcm-list">
|
|
184
|
+
<li v-for="it in open.kase.items" :key="it.itemNo">
|
|
185
|
+
<p v-if="it.title" class="bcm-topic-title">{{ it.title }}</p>
|
|
186
|
+
<!-- An opening post's item whose title kb-api withholds, or cleared with its deleted opening post (M3 FX2, S12: openingState): the title's own words. -->
|
|
187
|
+
<p v-else-if="it.title === null" class="bcm-topic-title bcm-placeholder">{{ missingTitle(it) }}</p>
|
|
188
|
+
<p v-if="it.body !== null" class="bcm-text">{{ it.body }}</p>
|
|
189
|
+
<!-- A message of the package beside the subject that others cannot see now: kb-api leaves its text out. -->
|
|
190
|
+
<p v-else class="bcm-placeholder">{{ t('moderation.itemWithheld') }}</p>
|
|
191
|
+
<p v-if="it.authorHandle || it.createdAt" class="bcm-row bcm-muted">
|
|
192
|
+
<span v-if="it.authorHandle" class="bcm-handle">{{ it.authorHandle }}</span>
|
|
193
|
+
<time v-if="it.createdAt" :datetime="it.createdAt">{{ formatDate(lang, it.createdAt) }}</time>
|
|
194
|
+
</p>
|
|
195
|
+
</li>
|
|
196
|
+
</ol>
|
|
197
|
+
|
|
198
|
+
<form v-if="open.kase && !open.decided" class="bcm-form" novalidate @submit.prevent="open.preview === null ? ctl.preview() : ctl.decide()">
|
|
199
|
+
<fieldset class="bcm-fieldset">
|
|
200
|
+
<legend>{{ t('moderation.restriction') }}</legend>
|
|
201
|
+
<label v-for="r in RESTRICTIONS" :key="r" class="bcm-check">
|
|
202
|
+
<input v-model="open.restriction" type="radio" :value="r" />
|
|
203
|
+
<span>{{ t(restrictionKey(r)) }}</span>
|
|
204
|
+
</label>
|
|
205
|
+
</fieldset>
|
|
206
|
+
<div class="bcm-field">
|
|
207
|
+
<label :for="`${id}-facts`">{{ t('statement.facts') }}</label>
|
|
208
|
+
<textarea :id="`${id}-facts`" v-model="open.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-facts-hint`" />
|
|
209
|
+
<p :id="`${id}-facts-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
|
|
210
|
+
</div>
|
|
211
|
+
<div v-if="ruleNeeded || open.ground !== ''" class="bcm-field">
|
|
212
|
+
<label :for="`${id}-ground`">{{ t('statement.contractualGround') }}</label>
|
|
213
|
+
<select :id="`${id}-ground`" v-model="open.ground" class="bcm-input bcm-select" :aria-invalid="open.needsRule && open.ground === ''">
|
|
214
|
+
<option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
|
|
215
|
+
<option v-for="r in state.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
|
|
216
|
+
</select>
|
|
217
|
+
</div>
|
|
218
|
+
<p v-if="open.failure" class="bcm-error" role="alert">{{ open.needsRule ? t('moderation.ruleRequired') : failureOf(open.failure) }}</p>
|
|
219
|
+
<template v-if="open.preview !== null">
|
|
220
|
+
<h4>{{ t('moderation.previewTitle') }}</h4>
|
|
221
|
+
<pre class="bcm-facts">{{ open.preview }}</pre>
|
|
222
|
+
</template>
|
|
223
|
+
<div class="bcm-row">
|
|
224
|
+
<button type="button" class="bcm-btn" @click="ctl.closeCase()">{{ t('common.close') }}</button>
|
|
225
|
+
<span class="bcm-spacer" />
|
|
226
|
+
<button v-if="open.preview === null" type="submit" class="bcm-btn" :disabled="!ctl.canPreview()">{{ t('moderation.preview') }}</button>
|
|
227
|
+
<button v-else type="submit" class="bcm-btn bcm-primary" :disabled="open.deciding">{{ t('moderation.decide') }}</button>
|
|
228
|
+
</div>
|
|
229
|
+
</form>
|
|
230
|
+
<template v-else-if="open.decided">
|
|
231
|
+
<p>{{ t('moderation.decided') }}</p>
|
|
232
|
+
<h4>{{ t('statement.title') }}</h4>
|
|
233
|
+
<pre class="bcm-facts">{{ open.preview }}</pre>
|
|
234
|
+
<div class="bcm-row">
|
|
235
|
+
<button type="button" class="bcm-btn" @click="ctl.closeCase()">{{ t('common.close') }}</button>
|
|
236
|
+
</div>
|
|
237
|
+
</template>
|
|
238
|
+
|
|
239
|
+
<!-- An illegal line's matter: to the team (the moderator sees nothing of it afterwards). -->
|
|
240
|
+
<div v-if="open.kase && !open.decided" class="bcm-section">
|
|
241
|
+
<button v-if="!open.escalation" type="button" class="bcm-link" @click="ctl.startEscalation()">{{ t('moderation.escalate') }}</button>
|
|
242
|
+
<form v-else-if="!open.escalation.done" class="bcm-form" novalidate @submit.prevent="ctl.escalate()">
|
|
243
|
+
<p>{{ t('moderation.escalateLede') }}</p>
|
|
244
|
+
<div class="bcm-field">
|
|
245
|
+
<label :for="`${id}-escalate`">{{ t('moderation.escalateCategory') }}</label>
|
|
246
|
+
<select :id="`${id}-escalate`" v-model="open.escalation.category" class="bcm-input bcm-select">
|
|
247
|
+
<option value="" disabled>{{ t('moderation.escalateCategory') }}</option>
|
|
248
|
+
<option v-for="c in ESCALATION_CATEGORIES" :key="c" :value="c">{{ t(categoryKey(c)) }}</option>
|
|
249
|
+
</select>
|
|
250
|
+
</div>
|
|
251
|
+
<p v-if="open.escalation.failure" class="bcm-error" role="alert">{{ failureOf(open.escalation.failure) }}</p>
|
|
252
|
+
<div class="bcm-row">
|
|
253
|
+
<button type="submit" class="bcm-btn" :disabled="open.escalation.category === '' || open.escalation.sending">{{ t('moderation.escalate') }}</button>
|
|
254
|
+
</div>
|
|
255
|
+
</form>
|
|
256
|
+
<p v-else>{{ t('moderation.escalated') }}</p>
|
|
257
|
+
</div>
|
|
258
|
+
</section>
|
|
259
|
+
|
|
260
|
+
<!-- A pre-moderated message: published without a statement, or refused with one. -->
|
|
261
|
+
<section v-if="review" class="bcm-card" :aria-labelledby="`${id}-review`">
|
|
262
|
+
<h3 :id="`${id}-review`">{{ review.outcome === 'approve' ? t('moderation.approve') : t('moderation.reject') }}</h3>
|
|
263
|
+
<template v-if="review.done">
|
|
264
|
+
<p>{{ review.done === 'approved' ? t('moderation.approved') : t('moderation.rejected') }}</p>
|
|
265
|
+
<pre v-if="review.statement" class="bcm-facts">{{ review.statement }}</pre>
|
|
266
|
+
<div class="bcm-row">
|
|
267
|
+
<button type="button" class="bcm-btn" @click="ctl.closeReview()">{{ t('common.close') }}</button>
|
|
268
|
+
</div>
|
|
269
|
+
</template>
|
|
270
|
+
<form v-else class="bcm-form" novalidate @submit.prevent="ctl.review()">
|
|
271
|
+
<!-- R3: the text the decision is about; an edit by its author meanwhile brings the new one. -->
|
|
272
|
+
<p v-if="review.changed" class="bcm-banner" role="alert">{{ t('moderation.textChanged') }}</p>
|
|
273
|
+
<p v-if="review.item.title" class="bcm-topic-title">{{ review.item.title }}</p>
|
|
274
|
+
<p v-if="review.item.body !== null" class="bcm-text">{{ review.item.body }}</p>
|
|
275
|
+
<template v-if="review.outcome === 'reject'">
|
|
276
|
+
<div class="bcm-field">
|
|
277
|
+
<label :for="`${id}-review-rule`">{{ t('statement.contractualGround') }}</label>
|
|
278
|
+
<select :id="`${id}-review-rule`" v-model="review.ruleId" class="bcm-input bcm-select">
|
|
279
|
+
<option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
|
|
280
|
+
<option v-for="r in state.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
|
|
281
|
+
</select>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="bcm-field">
|
|
284
|
+
<label :for="`${id}-review-facts`">{{ t('statement.facts') }}</label>
|
|
285
|
+
<textarea :id="`${id}-review-facts`" v-model="review.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-review-hint`" />
|
|
286
|
+
<p :id="`${id}-review-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
|
|
287
|
+
</div>
|
|
288
|
+
</template>
|
|
289
|
+
<p v-if="review.failure" class="bcm-error" role="alert">{{ failureOf(review.failure) }}</p>
|
|
290
|
+
<div class="bcm-row">
|
|
291
|
+
<button type="button" class="bcm-btn" :disabled="review.sending" @click="ctl.closeReview()">{{ t('common.cancel') }}</button>
|
|
292
|
+
<span class="bcm-spacer" />
|
|
293
|
+
<button
|
|
294
|
+
type="submit"
|
|
295
|
+
class="bcm-btn bcm-primary"
|
|
296
|
+
:class="{ 'bcm-danger': review.outcome === 'reject' }"
|
|
297
|
+
:disabled="review.sending || (review.outcome === 'reject' && (review.ruleId === '' || review.facts.trim() === ''))"
|
|
298
|
+
>
|
|
299
|
+
{{ review.outcome === 'approve' ? t('moderation.approve') : t('moderation.reject') }}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
</form>
|
|
303
|
+
</section>
|
|
304
|
+
</template>
|
|
305
|
+
|
|
306
|
+
<!-- The one live region: a decision, an escalation or a review is announced here once (their lines carry no role). -->
|
|
307
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
308
|
+
</section>
|
|
309
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The forum's notices from `me/get` (m3-contracts.md §4.8, §4.9; D-M3-9): the team's away mode in one sentence
|
|
4
|
+
* („Tým je teď nepřítomen; nová témata počkají na kontrolu."), and the pause of the forum's writes — the module's brake
|
|
5
|
+
* or the forum's own (the article discussions go on under the latter) — or the module's `read_only`. Reading goes on.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { MyState } from '@blogmcp/sdk/community'
|
|
9
|
+
import { forumNoticesOf, pausedKey } from '../core/forum-state'
|
|
10
|
+
import { useText } from './context'
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ me: MyState | null; now?: number }>()
|
|
13
|
+
const { t } = useText()
|
|
14
|
+
|
|
15
|
+
const notices = computed(() => forumNoticesOf(props.me, props.now))
|
|
16
|
+
const paused = computed(() => pausedKey(notices.value.paused))
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<p v-if="paused" class="bcm-note" role="status">{{ t(paused) }}</p>
|
|
21
|
+
<p v-else-if="notices.readOnly" class="bcm-note" role="status">{{ t('error.readOnly') }}</p>
|
|
22
|
+
<p v-if="notices.away" class="bcm-note" role="status">{{ t('state.away') }}</p>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-forum-search>` (m3-contracts.md §3.2, §4.4, §5.3; D-M3-5): the forum's search for its members — a query of
|
|
4
|
+
* 2 to 200 characters (said before it goes: kb-api would refuse it and spend the quota), the space (the forum, the
|
|
5
|
+
* article discussions or both) and a category to narrow by; 20 hits a page, five pages at most. A hit reads as text:
|
|
6
|
+
* a topic's title or a message's snippet (at most 200 characters, no markup), linked to the topic (a message by its
|
|
7
|
+
* anchor) — an article discussion's hit to its message on the article (the host's `articlePath` with kb-api's
|
|
8
|
+
* `articleSlug`, the wave 2 integration; a host without `articlePath` keeps it text); a topic's title reads as on a
|
|
9
|
+
* category's row (topicTitleOf — M3 FX2, S12: „Smazané téma" for a hit under a deleted opening post, kb-api's
|
|
10
|
+
* `openingState`; the withheld wording for a title kept from the viewer). The forum's search box brings the query in
|
|
11
|
+
* the page's fragment (`#q=…`), which never reaches a server; a new `#q=` on the same page searches again.
|
|
12
|
+
*/
|
|
13
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
14
|
+
import {
|
|
15
|
+
COMMUNITY_SEARCH_PAGES_MAX,
|
|
16
|
+
COMMUNITY_SEARCH_QUERY_MAX,
|
|
17
|
+
COMMUNITY_SEARCH_QUERY_MIN,
|
|
18
|
+
communityMessageLink,
|
|
19
|
+
topicMessageLink,
|
|
20
|
+
topicPath,
|
|
21
|
+
topicTitleOf,
|
|
22
|
+
type CommunityTransport,
|
|
23
|
+
type ForumSearchResult,
|
|
24
|
+
} from '@blogmcp/sdk/community'
|
|
25
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
26
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
27
|
+
import { createSearch, queryFromHash, type SearchController } from '../core/forum-search'
|
|
28
|
+
import { formatDate } from '../core/format'
|
|
29
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
30
|
+
import { failureText } from '../core/outcomes'
|
|
31
|
+
import MembersOnly from './MembersOnly.vue'
|
|
32
|
+
import { provideText, uid } from './context'
|
|
33
|
+
|
|
34
|
+
defineOptions({ inheritAttrs: false })
|
|
35
|
+
|
|
36
|
+
const props = defineProps<{
|
|
37
|
+
apiBase?: string
|
|
38
|
+
siteBase?: string
|
|
39
|
+
lang?: string
|
|
40
|
+
routes?: string | Record<string, unknown>
|
|
41
|
+
signInHref?: string
|
|
42
|
+
transport?: CommunityTransport
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
46
|
+
const { t } = provideText(lang)
|
|
47
|
+
const id = uid('bcm-search')
|
|
48
|
+
watch(
|
|
49
|
+
() => props.lang,
|
|
50
|
+
(v) => {
|
|
51
|
+
lang.value = pickLang(v, documentLang())
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
56
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
57
|
+
const ctl: SearchController | null = client ? createSearch({ client, query: queryFromHash(typeof location === 'undefined' ? null : location.hash) }) : null
|
|
58
|
+
const state = ctl?.state ?? null
|
|
59
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
|
|
60
|
+
|
|
61
|
+
// A new `#q=` on the same page (the forum's search box there, the history's back and forward) is a new search.
|
|
62
|
+
function onHashChange(): void {
|
|
63
|
+
const q = queryFromHash(location.hash)
|
|
64
|
+
if (!ctl || !state || q === null || q === state.query) return
|
|
65
|
+
state.query = q
|
|
66
|
+
void ctl.search()
|
|
67
|
+
}
|
|
68
|
+
onMounted(() => {
|
|
69
|
+
void ctl?.load()
|
|
70
|
+
if (typeof window !== 'undefined') window.addEventListener('hashchange', onHashChange)
|
|
71
|
+
})
|
|
72
|
+
onBeforeUnmount(() => {
|
|
73
|
+
if (typeof window !== 'undefined') window.removeEventListener('hashchange', onHashChange)
|
|
74
|
+
ctl?.dispose()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/** A hit's link: the topic (a message by its anchor), or an article discussion's message on its article. */
|
|
78
|
+
function hrefOf(r: ForumSearchResult): string | null {
|
|
79
|
+
if (r.space === 'article') return communityMessageLink(routes.value.articlePath, r.articleSlug, r.seq)
|
|
80
|
+
if (r.space !== 'forum') return null
|
|
81
|
+
return r.seq !== null && r.kind === 'message' ? topicMessageLink(routes.value.topicPath, r.conversationId, r.seq) : topicPath(routes.value.topicPath, r.conversationId)
|
|
82
|
+
}
|
|
83
|
+
/** A hit's title: a topic's as topicTitleOf reads it (shown, deleted, withheld); an article discussion's, its article's. */
|
|
84
|
+
function titleOf(r: ForumSearchResult): string {
|
|
85
|
+
if (r.space === 'forum') {
|
|
86
|
+
const ti = topicTitleOf(r)
|
|
87
|
+
return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
|
|
88
|
+
}
|
|
89
|
+
return r.title !== null && r.title.trim() !== '' ? r.title : t('search.inArticle')
|
|
90
|
+
}
|
|
91
|
+
const failure = computed(() => {
|
|
92
|
+
const f = state?.failure
|
|
93
|
+
if (!f) return ''
|
|
94
|
+
const r = failureText(f, lang.value)
|
|
95
|
+
return t(r.key, r.params)
|
|
96
|
+
})
|
|
97
|
+
const lastPage = computed(() => !!state && state.phase === 'ready' && state.page >= COMMUNITY_SEARCH_PAGES_MAX && state.results.length > 0)
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<section v-if="ctl && state && state.phase !== 'hidden'" class="bcm bcm-forum-search" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
102
|
+
<header class="bcm-head">
|
|
103
|
+
<h2 :id="`${id}-title`">{{ t('search.title') }}</h2>
|
|
104
|
+
</header>
|
|
105
|
+
|
|
106
|
+
<MembersOnly
|
|
107
|
+
v-if="state.phase === 'membersOnly' && client"
|
|
108
|
+
:failure="state.failure"
|
|
109
|
+
:client="client"
|
|
110
|
+
:lang="lang"
|
|
111
|
+
:sign-in-href="signInHref"
|
|
112
|
+
:rules-href="routes.rulesPath"
|
|
113
|
+
@joined="ctl.load()"
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
<!-- An element, not a fragment: it holds a form (see ForumView). -->
|
|
117
|
+
<div v-else class="bcm-search-body">
|
|
118
|
+
<form class="bcm-form" role="search" novalidate @submit.prevent="ctl.search()">
|
|
119
|
+
<div class="bcm-field">
|
|
120
|
+
<label :for="`${id}-q`">{{ t('search.label') }}</label>
|
|
121
|
+
<input :id="`${id}-q`" v-model="state.query" class="bcm-input" type="search" :aria-invalid="state.invalid !== null" :aria-describedby="`${id}-q-error`" />
|
|
122
|
+
<p v-if="state.invalid" :id="`${id}-q-error`" class="bcm-error" role="alert">
|
|
123
|
+
{{ state.invalid === 'short' ? t('search.tooShort', { min: COMMUNITY_SEARCH_QUERY_MIN }) : t('search.tooLong', { max: COMMUNITY_SEARCH_QUERY_MAX }) }}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="bcm-row">
|
|
127
|
+
<label :for="`${id}-space`">{{ t('search.space') }}</label>
|
|
128
|
+
<select :id="`${id}-space`" v-model="state.space" class="bcm-input bcm-select">
|
|
129
|
+
<option value="">{{ t('search.spaceAll') }}</option>
|
|
130
|
+
<option value="forum">{{ t('search.spaceForum') }}</option>
|
|
131
|
+
<option value="article">{{ t('search.spaceArticle') }}</option>
|
|
132
|
+
</select>
|
|
133
|
+
<template v-if="state.categories.length > 0 && state.space !== 'article'">
|
|
134
|
+
<label :for="`${id}-category`">{{ t('search.category') }}</label>
|
|
135
|
+
<select :id="`${id}-category`" v-model="state.categoryId" class="bcm-input bcm-select">
|
|
136
|
+
<option value="">{{ t('forum.allCategories') }}</option>
|
|
137
|
+
<option v-for="c in state.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
|
138
|
+
</select>
|
|
139
|
+
</template>
|
|
140
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="state.phase === 'searching'">{{ t('search.submit') }}</button>
|
|
141
|
+
</div>
|
|
142
|
+
</form>
|
|
143
|
+
|
|
144
|
+
<p v-if="state.phase === 'searching'" class="bcm-muted" role="status">{{ t('search.searching') }}</p>
|
|
145
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
146
|
+
<span>{{ failure }}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<template v-else-if="state.phase === 'ready'">
|
|
149
|
+
<p v-if="state.results.length === 0" class="bcm-muted" role="status">{{ t('search.empty') }}</p>
|
|
150
|
+
<ol v-else class="bcm-list">
|
|
151
|
+
<li v-for="(r, i) in state.results" :key="`${r.conversationId}:${r.seq ?? 0}:${i}`">
|
|
152
|
+
<p class="bcm-row bcm-muted">
|
|
153
|
+
<span class="bcm-badge">{{ r.kind === 'topic' ? t('search.topic') : t('search.message') }}</span>
|
|
154
|
+
<time :datetime="r.createdAt">{{ formatDate(lang, r.createdAt) }}</time>
|
|
155
|
+
</p>
|
|
156
|
+
<p class="bcm-topic-title">
|
|
157
|
+
<a v-if="hrefOf(r)" :href="hrefOf(r) ?? undefined" :class="{ 'bcm-placeholder': r.title === null }">{{ titleOf(r) }}</a>
|
|
158
|
+
<span v-else :class="{ 'bcm-placeholder': r.title === null }">{{ titleOf(r) }}</span>
|
|
159
|
+
</p>
|
|
160
|
+
<p v-if="r.snippet" class="bcm-text">{{ r.snippet }}</p>
|
|
161
|
+
</li>
|
|
162
|
+
</ol>
|
|
163
|
+
<p v-if="state.failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
164
|
+
<div v-if="state.hasMore" class="bcm-row">
|
|
165
|
+
<button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.more()">{{ t('search.more') }}</button>
|
|
166
|
+
</div>
|
|
167
|
+
<p v-else-if="lastPage" class="bcm-hint">{{ t('search.lastPage') }}</p>
|
|
168
|
+
</template>
|
|
169
|
+
</div>
|
|
170
|
+
</section>
|
|
171
|
+
</template>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-forum>` (m3-contracts.md §5.3): the forum's index — the listed categories in the team's order, each with its
|
|
4
|
+
* description, how many topics (in „Články": article discussions) another member sees there, its last activity and its
|
|
5
|
+
* latest topics, and the search box (it opens the search page with the query in the fragment: `#q=…` never reaches a
|
|
6
|
+
* server). The forum is for its members: anyone else reads one sentence and the way in; a forum switched off renders
|
|
7
|
+
* nothing. Away mode and the forum's pause read one sentence each.
|
|
8
|
+
*/
|
|
9
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
10
|
+
import { COMMUNITY_SEARCH_QUERY_MIN, categoryPath, type CommunityTransport, type ForumCategory } from '@blogmcp/sdk/community'
|
|
11
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
12
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
13
|
+
import { createForum, type ForumController } from '../core/forum'
|
|
14
|
+
import { searchLink } from '../core/forum-search'
|
|
15
|
+
import { formatDate } from '../core/format'
|
|
16
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
17
|
+
import { failureText } from '../core/outcomes'
|
|
18
|
+
import ForumNotices from './ForumNotices.vue'
|
|
19
|
+
import MembersOnly from './MembersOnly.vue'
|
|
20
|
+
import TopicRow from './TopicRow.vue'
|
|
21
|
+
import { provideText, uid } from './context'
|
|
22
|
+
|
|
23
|
+
defineOptions({ inheritAttrs: false })
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
apiBase?: string
|
|
27
|
+
siteBase?: string
|
|
28
|
+
lang?: string
|
|
29
|
+
/** JSON (or an object): the host's pages — `forumPath`, `categoryPath`, `topicPath`, `searchPath`, `rulesPath`, … */
|
|
30
|
+
routes?: string | Record<string, unknown>
|
|
31
|
+
signInHref?: string
|
|
32
|
+
transport?: CommunityTransport
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
36
|
+
const { t } = provideText(lang)
|
|
37
|
+
const id = uid('bcm-forum')
|
|
38
|
+
|
|
39
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
40
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
41
|
+
const ctl: ForumController | null = client ? createForum({ client }) : null
|
|
42
|
+
const state = ctl?.state ?? null
|
|
43
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
|
|
44
|
+
|
|
45
|
+
onMounted(() => void ctl?.load())
|
|
46
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
47
|
+
watch(
|
|
48
|
+
() => props.lang,
|
|
49
|
+
(v) => {
|
|
50
|
+
lang.value = pickLang(v, documentLang())
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const query = ref('')
|
|
55
|
+
const queryShort = ref(false)
|
|
56
|
+
function search(): void {
|
|
57
|
+
const href = searchLink(routes.value.searchPath, query.value)
|
|
58
|
+
queryShort.value = href === null
|
|
59
|
+
if (href !== null && typeof location !== 'undefined') location.assign(href)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const failure = computed(() => {
|
|
63
|
+
const f = state?.failure
|
|
64
|
+
if (!f) return ''
|
|
65
|
+
const r = failureText(f, lang.value)
|
|
66
|
+
return t(r.key, r.params)
|
|
67
|
+
})
|
|
68
|
+
const countText = (c: ForumCategory): string =>
|
|
69
|
+
c.kind === 'article_discussions' ? t('forum.discussionCount', { count: c.topicCount }) : t('forum.topicCount', { count: c.topicCount })
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<section v-if="ctl && state && state.phase !== 'hidden'" class="bcm bcm-forum" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
74
|
+
<header class="bcm-head">
|
|
75
|
+
<h2 :id="`${id}-title`">{{ t('forum.title') }}</h2>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('forum.loading') }}</p>
|
|
79
|
+
<MembersOnly
|
|
80
|
+
v-else-if="state.phase === 'membersOnly' && client"
|
|
81
|
+
:failure="state.failure"
|
|
82
|
+
:client="client"
|
|
83
|
+
:lang="lang"
|
|
84
|
+
:sign-in-href="signInHref"
|
|
85
|
+
:rules-href="routes.rulesPath"
|
|
86
|
+
@joined="ctl.load()"
|
|
87
|
+
/>
|
|
88
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
89
|
+
<span>{{ failure }}</span>
|
|
90
|
+
<button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- An element, not a fragment: it holds a form, and happy-dom (the specs) answers a form's nextSibling with null,
|
|
94
|
+
which a fragment's removal walks. -->
|
|
95
|
+
<div v-else-if="state.phase === 'ready'" class="bcm-forum-body">
|
|
96
|
+
<ForumNotices :me="state.me" />
|
|
97
|
+
|
|
98
|
+
<form class="bcm-row" role="search" @submit.prevent="search">
|
|
99
|
+
<label :for="`${id}-q`" class="bcm-sr">{{ t('forum.search') }}</label>
|
|
100
|
+
<input :id="`${id}-q`" v-model="query" class="bcm-input bcm-grow" type="search" :placeholder="t('forum.search')" />
|
|
101
|
+
<button type="submit" class="bcm-btn">{{ t('forum.searchSubmit') }}</button>
|
|
102
|
+
<p v-if="queryShort" class="bcm-error bcm-full" role="alert">{{ t('search.tooShort', { min: COMMUNITY_SEARCH_QUERY_MIN }) }}</p>
|
|
103
|
+
</form>
|
|
104
|
+
|
|
105
|
+
<p v-if="state.categories.length === 0" class="bcm-muted">{{ t('forum.empty') }}</p>
|
|
106
|
+
<ul v-else class="bcm-list">
|
|
107
|
+
<li v-for="c in state.categories" :key="c.id" class="bcm-category">
|
|
108
|
+
<h3>
|
|
109
|
+
<a v-if="categoryPath(routes.categoryPath, c.slug)" :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ c.name }}</a>
|
|
110
|
+
<template v-else>{{ c.name }}</template>
|
|
111
|
+
</h3>
|
|
112
|
+
<p v-if="c.description" class="bcm-text">{{ c.description }}</p>
|
|
113
|
+
<p class="bcm-row bcm-muted">
|
|
114
|
+
<span>{{ countText(c) }}</span>
|
|
115
|
+
<time v-if="c.lastActivityAt" :datetime="c.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, c.lastActivityAt) }) }}</time>
|
|
116
|
+
</p>
|
|
117
|
+
<p v-if="c.mode === 'pre'" class="bcm-hint">{{ t('forum.modePre') }}</p>
|
|
118
|
+
<ul v-if="state.latest[c.id]?.length" class="bcm-topics" :aria-label="t('forum.latest')">
|
|
119
|
+
<TopicRow v-for="item in state.latest[c.id]" :key="item.conversationId" :item="item" :topic-href="routes.topicPath" :article-href="routes.articlePath" />
|
|
120
|
+
</ul>
|
|
121
|
+
<p v-if="categoryPath(routes.categoryPath, c.slug)">
|
|
122
|
+
<a :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ t('forum.allTopics') }}</a>
|
|
123
|
+
</p>
|
|
124
|
+
</li>
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
</template>
|