@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,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The forum's answer to anyone it is not open to (m3-contracts.md §1.2, §3.1 step 3; D-M3-3): one sentence, and the
|
|
4
|
+
* way in — the sign-in link for a visitor (or a session the tenant does not admit as a member), the onboarding inline
|
|
5
|
+
* for a member session that has not finished joining (`needs` `onboarding` / `reconsent_rules`). The onboarding asked
|
|
6
|
+
* here is the one reading the forum needs: the art. 9 consent waits for the first write.
|
|
7
|
+
*/
|
|
8
|
+
import { computed, onBeforeUnmount, shallowRef } from 'vue'
|
|
9
|
+
import { signInLink, type CommunityClient, type CommunityFailure } from '@blogmcp/sdk/community'
|
|
10
|
+
import { membersOnlyAsk } from '../core/forum-state'
|
|
11
|
+
import type { Lang } from '../core/i18n'
|
|
12
|
+
import { createOnboarding, type OnboardingModel } from '../core/onboarding'
|
|
13
|
+
import OnboardingSteps from './OnboardingSteps.vue'
|
|
14
|
+
import { useText } from './context'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ failure: CommunityFailure | null; client: CommunityClient; lang: Lang; signInHref?: string; rulesHref: string }>()
|
|
17
|
+
const emit = defineEmits<{ joined: [] }>()
|
|
18
|
+
const { t } = useText()
|
|
19
|
+
|
|
20
|
+
const ask = computed(() => membersOnlyAsk(props.failure))
|
|
21
|
+
const model = shallowRef<OnboardingModel | null>(null)
|
|
22
|
+
const abort = new AbortController()
|
|
23
|
+
onBeforeUnmount(() => abort.abort())
|
|
24
|
+
|
|
25
|
+
function signInHref(): string | null {
|
|
26
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function join(): void {
|
|
30
|
+
if (model.value) return
|
|
31
|
+
const m = createOnboarding({
|
|
32
|
+
client: props.client,
|
|
33
|
+
lang: props.lang,
|
|
34
|
+
signal: abort.signal,
|
|
35
|
+
onDone: () => {
|
|
36
|
+
model.value = null
|
|
37
|
+
emit('joined')
|
|
38
|
+
},
|
|
39
|
+
onSignIn: () => {
|
|
40
|
+
model.value = null
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
model.value = m
|
|
44
|
+
void m.load()
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="bcm-section">
|
|
50
|
+
<p class="bcm-note">{{ t('error.membersOnly') }}</p>
|
|
51
|
+
<OnboardingSteps v-if="model" :model="model" :rules-href="rulesHref" @cancel="model = null" />
|
|
52
|
+
<div v-else-if="ask === 'join'" class="bcm-row">
|
|
53
|
+
<button type="button" class="bcm-btn bcm-primary" @click="join">{{ t('forum.join') }}</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div v-else-if="signInHref()" class="bcm-row">
|
|
56
|
+
<a class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
@@ -6,17 +6,31 @@
|
|
|
6
6
|
* asks for one (the same text goes again with `confirmations`). M2 T10: with the member's mention cap
|
|
7
7
|
* (`mentionsMax`, me/get's `caps.mentionsPerMessage`), a draft that mentions more distinct `@handle`s says so as it
|
|
8
8
|
* is typed (`error.mentionLimit`) and is not sent; kb-api's MENTION_LIMIT stays the rule.
|
|
9
|
+
*
|
|
10
|
+
* M3 (m3-contracts.md §3.3, §5.3; B21): a draft with a `title` (a string) asks for a topic's title too — a new topic
|
|
11
|
+
* (`kind` `topic`, its body the opening post) or its author's edit of the opening post — 1 to 200 UTF-16 units, as
|
|
12
|
+
* kb-api counts them (forumTopicTitleOf); the link to the rules stays, as on every composer.
|
|
9
13
|
*/
|
|
10
14
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
11
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
COMMUNITY_MESSAGE_LINKS_MAX,
|
|
17
|
+
COMMUNITY_MESSAGE_MAX,
|
|
18
|
+
COMMUNITY_TOPIC_TITLE_MAX,
|
|
19
|
+
communityMentionHandles,
|
|
20
|
+
confirmMessageKey,
|
|
21
|
+
forumTopicTitleOf,
|
|
22
|
+
topicTitleLength,
|
|
23
|
+
} from '@blogmcp/sdk/community'
|
|
12
24
|
import type { Draft } from '../core/discussion'
|
|
13
25
|
import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
|
|
14
26
|
import { codePointLength, normalizeBody } from '../core/text'
|
|
15
27
|
import { uid, useText } from './context'
|
|
16
28
|
|
|
17
29
|
const props = defineProps<{
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
/** M3: `title` (a string) asks for a topic's title too; null or absent: no title. */
|
|
31
|
+
draft: Draft & { title?: string | null }
|
|
32
|
+
/** M3: `topic` — a new topic (the title and its opening post). */
|
|
33
|
+
kind: 'new' | 'reply' | 'edit' | 'topic'
|
|
20
34
|
/** Seconds the draft still waits (the controller's clock). */
|
|
21
35
|
wait: number
|
|
22
36
|
replyingTo?: string | null
|
|
@@ -36,12 +50,23 @@ const mentionsOver = computed(() => {
|
|
|
36
50
|
const max = props.mentionsMax
|
|
37
51
|
return typeof max === 'number' && max >= 0 && communityMentionHandles(normalizeBody(props.draft.body)).length > max
|
|
38
52
|
})
|
|
39
|
-
|
|
53
|
+
/** M3: the draft asks for a topic's title. */
|
|
54
|
+
const titled = computed(() => typeof props.draft.title === 'string')
|
|
55
|
+
const titleLength = computed(() => topicTitleLength(props.draft.title))
|
|
56
|
+
const titleOver = computed(() => titled.value && titleLength.value > COMMUNITY_TOPIC_TITLE_MAX)
|
|
57
|
+
const titleOk = computed(() => !titled.value || forumTopicTitleOf(props.draft.title) !== null)
|
|
58
|
+
/** The post is written and the title is still blank: said where the title goes (the button waits for it). */
|
|
59
|
+
const titleMissing = computed(() => titled.value && !titleOver.value && !titleOk.value && length.value > 0)
|
|
60
|
+
const canSend = computed(
|
|
61
|
+
() => !props.draft.sending && length.value > 0 && !over.value && !mentionsOver.value && titleOk.value && props.wait === 0 && props.draft.confirm === null,
|
|
62
|
+
)
|
|
40
63
|
const allConfirmed = computed(() => {
|
|
41
64
|
const c = props.draft.confirm
|
|
42
65
|
return c !== null && c.codes.every((code) => c.checked.includes(code))
|
|
43
66
|
})
|
|
44
|
-
const label = computed(() =>
|
|
67
|
+
const label = computed(() =>
|
|
68
|
+
t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : props.kind === 'topic' ? 'topic.bodyLabel' : 'composer.label'),
|
|
69
|
+
)
|
|
45
70
|
const keys = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘ + Enter' : 'Ctrl + Enter'
|
|
46
71
|
const failure = computed(() => {
|
|
47
72
|
const f = props.draft.failure
|
|
@@ -69,9 +94,9 @@ onBeforeUnmount(() => {
|
|
|
69
94
|
})
|
|
70
95
|
|
|
71
96
|
// A changed text is a new attempt: the old refusal and the confirmation it asked for no longer apply
|
|
72
|
-
// (a cooldown still does).
|
|
97
|
+
// (a cooldown still does). M3: the title is part of the text.
|
|
73
98
|
watch(
|
|
74
|
-
() => props.draft.body,
|
|
99
|
+
() => [props.draft.body, props.draft.title] as const,
|
|
75
100
|
() => {
|
|
76
101
|
const d = props.draft
|
|
77
102
|
if (d.confirm) d.confirm = null
|
|
@@ -94,6 +119,23 @@ defineExpose({ focus: () => input.value?.focus() })
|
|
|
94
119
|
|
|
95
120
|
<template>
|
|
96
121
|
<form class="bcm-composer" @submit.prevent="onSubmit">
|
|
122
|
+
<template v-if="titled">
|
|
123
|
+
<label :for="`${id}-title`" class="bcm-label">{{ t('topic.titleLabel') }}</label>
|
|
124
|
+
<input
|
|
125
|
+
:id="`${id}-title`"
|
|
126
|
+
v-model="draft.title"
|
|
127
|
+
class="bcm-input"
|
|
128
|
+
type="text"
|
|
129
|
+
autocomplete="off"
|
|
130
|
+
:aria-describedby="`${id}-title-counter`"
|
|
131
|
+
:aria-invalid="titleOver"
|
|
132
|
+
:readonly="draft.sending"
|
|
133
|
+
@keydown="onKey"
|
|
134
|
+
/>
|
|
135
|
+
<span :id="`${id}-title-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': titleOver }">{{ t('composer.counter', { length: titleLength, max: COMMUNITY_TOPIC_TITLE_MAX }) }}</span>
|
|
136
|
+
<p v-if="titleOver" class="bcm-error">{{ t('topic.titleTooLong', { max: COMMUNITY_TOPIC_TITLE_MAX }) }}</p>
|
|
137
|
+
<p v-else-if="titleMissing" class="bcm-hint">{{ t('topic.titleMissing') }}</p>
|
|
138
|
+
</template>
|
|
97
139
|
<label :for="id" class="bcm-label">{{ label }}</label>
|
|
98
140
|
<p v-if="replyingTo" class="bcm-hint">{{ t('composer.replyingTo', { name: replyingTo }) }}</p>
|
|
99
141
|
<textarea
|
|
@@ -112,9 +154,9 @@ defineExpose({ focus: () => input.value?.focus() })
|
|
|
112
154
|
<span :id="`${id}-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': over }">{{ t('composer.counter', { length, max: COMMUNITY_MESSAGE_MAX }) }}</span>
|
|
113
155
|
<span class="bcm-sr" aria-live="polite">{{ counterLive }}</span>
|
|
114
156
|
<span class="bcm-spacer" />
|
|
115
|
-
<button v-if="kind !== 'new'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
|
|
157
|
+
<button v-if="kind !== 'new' && kind !== 'topic'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
|
|
116
158
|
<button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">
|
|
117
|
-
{{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : t('composer.submit') }}
|
|
159
|
+
{{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : kind === 'topic' ? t('topic.create') : t('composer.submit') }}
|
|
118
160
|
</button>
|
|
119
161
|
</div>
|
|
120
162
|
<div :id="`${id}-hints`" class="bcm-hint">
|
package/src/ui/MessageItem.vue
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* and while the discussion asks this member for the consent it offers the onboarding's consent step in place
|
|
12
12
|
* (`beginOnboarding`, the composer's own). kb-api sends the flag on the author's read only, so another reader sees
|
|
13
13
|
* the plain placeholder.
|
|
14
|
+
*
|
|
15
|
+
* M3 (m3-contracts.md §3.5, §4.5, §5.3): under a topic — „Moderátor" beside a member's message in a category the member
|
|
16
|
+
* moderates now (`author.moderator`, the only place the label shows), the topic's title in its author's edit of the
|
|
17
|
+
* opening post, and a category moderator's items in the menu (the topic's context provides them). A message waiting in
|
|
18
|
+
* pre-moderation reads „Čeká na kontrolu" to its author, as any held one.
|
|
14
19
|
*/
|
|
15
20
|
import { computed } from 'vue'
|
|
16
21
|
import { communityMessageAnchor, hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
|
|
@@ -29,7 +34,7 @@ defineOptions({ name: 'MessageItem' })
|
|
|
29
34
|
|
|
30
35
|
const props = defineProps<{ message: DiscussionMessage }>()
|
|
31
36
|
const { t, lang } = useText()
|
|
32
|
-
const { ctl, routes, opened, originals, allReplies, deleteMessage } = useDiscussion()
|
|
37
|
+
const { ctl, routes, opened, originals, allReplies, deleteMessage, extraMenu, onExtraMenu } = useDiscussion()
|
|
33
38
|
|
|
34
39
|
/** Replies shown before „Zobrazit další odpovědi". */
|
|
35
40
|
const REPLIES_SHOWN = 3
|
|
@@ -61,6 +66,8 @@ const authorName = computed(() => {
|
|
|
61
66
|
return a.displayName ?? t('author.deleted')
|
|
62
67
|
})
|
|
63
68
|
const handle = computed(() => (m.value.author.kind === 'member' ? m.value.author.handle : null))
|
|
69
|
+
/** M3 (D-M3-7, §4.5): an appointment acting now, in this topic's category — never a trust level. */
|
|
70
|
+
const moderatorLabel = computed(() => m.value.author.kind === 'member' && 'moderator' in m.value.author && m.value.author.moderator === true)
|
|
64
71
|
const badge = computed(() => {
|
|
65
72
|
const a = m.value.author
|
|
66
73
|
if (a.kind !== 'staff') return null
|
|
@@ -106,6 +113,8 @@ const menu = computed<MenuItem[]>(() => {
|
|
|
106
113
|
if (href) items.push({ id: 'report', label: t('discussion.report'), href })
|
|
107
114
|
}
|
|
108
115
|
if (ctl.canBlock(msg)) items.push({ id: msg.collapsed ? 'unblock' : 'block', label: msg.collapsed ? t('discussion.unblock') : t('discussion.block') })
|
|
116
|
+
// M3: a category moderator's items (the topic's context names them; none elsewhere).
|
|
117
|
+
if (extraMenu) items.push(...extraMenu(msg))
|
|
109
118
|
return items
|
|
110
119
|
})
|
|
111
120
|
|
|
@@ -115,6 +124,7 @@ function onMenu(id: string): void {
|
|
|
115
124
|
else if (id === 'delete') deleteMessage(msg.seq)
|
|
116
125
|
else if (id === 'report') ctl.openReport(msg)
|
|
117
126
|
else if ((id === 'block' || id === 'unblock') && msg.author.kind === 'member' && msg.author.memberId) void ctl.setBlocked(msg.author.memberId, id === 'block')
|
|
127
|
+
else onExtraMenu?.(id, msg)
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
function reply(): void {
|
|
@@ -135,6 +145,7 @@ function toggle(set: Set<number>): void {
|
|
|
135
145
|
<header class="bcm-meta">
|
|
136
146
|
<span class="bcm-author">{{ authorName }}</span>
|
|
137
147
|
<span v-if="badge" class="bcm-badge bcm-badge-team">{{ badge }}</span>
|
|
148
|
+
<span v-if="moderatorLabel" class="bcm-badge bcm-badge-team">{{ t('role.moderator') }}</span>
|
|
138
149
|
<span v-if="handle" class="bcm-handle">{{ handle }}</span>
|
|
139
150
|
<time v-if="m.createdAt" class="bcm-time" :datetime="m.createdAt">{{ formatDate(lang, m.createdAt) }}</time>
|
|
140
151
|
<span v-if="m.isEdited && !placeholder" class="bcm-muted">{{ t('discussion.edited') }}</span>
|
package/src/ui/MessageMenu.vue
CHANGED
|
@@ -8,7 +8,8 @@ import { nextTick, onBeforeUnmount, ref } from 'vue'
|
|
|
8
8
|
import { uid, useText } from './context'
|
|
9
9
|
import type { MenuItem } from './types'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** M3: the toggle's own text (a topic's moderator menu); absent: „Další akce". */
|
|
12
|
+
defineProps<{ items: MenuItem[]; label?: string }>()
|
|
12
13
|
const emit = defineEmits<{ select: [id: string] }>()
|
|
13
14
|
const { t } = useText()
|
|
14
15
|
|
|
@@ -55,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onOutside, tru
|
|
|
55
56
|
:aria-controls="listId"
|
|
56
57
|
@click="setOpen(!open)"
|
|
57
58
|
>
|
|
58
|
-
{{ t('discussion.menu') }}
|
|
59
|
+
{{ label ?? t('discussion.menu') }}
|
|
59
60
|
</button>
|
|
60
61
|
<div v-if="open" :id="listId" class="bcm-menu-list">
|
|
61
62
|
<template v-for="item in items" :key="item.id">
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A category moderator's dialog in a topic (m3-contracts.md §3.4, §4.5; D-M3-6): remove a message — the rule of the
|
|
4
|
+
* tenant's rules text it breaks and the facts, as the statement of reasons will state them; the statement comes back
|
|
5
|
+
* and is shown —, pass a message to the team (an illegal line's matter: the moderator sees nothing of it afterwards), or
|
|
6
|
+
* move the topic into another category they moderate. A native modal dialog; the focus returns where it was.
|
|
7
|
+
*/
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import { ESCALATION_CATEGORIES, type CaseCategory, type CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
10
|
+
import type { TopicController } from '../core/topic'
|
|
11
|
+
import { failureText } from '../core/outcomes'
|
|
12
|
+
import { codePointLength, normalizeBody } from '../core/text'
|
|
13
|
+
import { uid, useModal, useText } from './context'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{ ctl: TopicController; fallbackFocus?: () => HTMLElement | null }>()
|
|
16
|
+
const emit = defineEmits<{ close: [] }>()
|
|
17
|
+
const { t, lang } = useText()
|
|
18
|
+
|
|
19
|
+
const dialog = ref<HTMLDialogElement | null>(null)
|
|
20
|
+
const id = uid('bcm-moderator')
|
|
21
|
+
useModal(dialog, () => emit('close'), props.fallbackFocus)
|
|
22
|
+
|
|
23
|
+
const d = computed(() => props.ctl.extras.dialog)
|
|
24
|
+
const titleKey = computed<CommunityMessageKey>(() => (d.value?.kind === 'remove' ? 'moderation.remove' : d.value?.kind === 'escalate' ? 'moderation.escalate' : 'moderation.move'))
|
|
25
|
+
const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
|
|
26
|
+
const failure = computed(() => {
|
|
27
|
+
const f = d.value?.failure
|
|
28
|
+
if (!f) return ''
|
|
29
|
+
const r = failureText(f, lang.value)
|
|
30
|
+
return t(r.key, r.params)
|
|
31
|
+
})
|
|
32
|
+
const canSend = computed(() => {
|
|
33
|
+
const x = d.value
|
|
34
|
+
if (!x || x.sending || x.done) return false
|
|
35
|
+
if (x.kind === 'move') return x.categoryId !== ''
|
|
36
|
+
if (x.kind === 'escalate') return x.category !== ''
|
|
37
|
+
const facts = normalizeBody(x.facts)
|
|
38
|
+
return x.ruleId !== '' && facts !== '' && codePointLength(facts) <= 4000
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<dialog ref="dialog" class="bcm-dialog" :aria-labelledby="`${id}-title`">
|
|
44
|
+
<form v-if="d" class="bcm-dialog-body bcm-form" novalidate @submit.prevent="canSend && ctl.submitDialog()">
|
|
45
|
+
<h3 :id="`${id}-title`">{{ t(titleKey) }}</h3>
|
|
46
|
+
|
|
47
|
+
<template v-if="d.kind === 'remove'">
|
|
48
|
+
<template v-if="!d.done">
|
|
49
|
+
<p>{{ t('moderation.removeLede') }}</p>
|
|
50
|
+
<div class="bcm-field">
|
|
51
|
+
<label :for="`${id}-rule`">{{ t('statement.contractualGround') }}</label>
|
|
52
|
+
<select :id="`${id}-rule`" v-model="d.ruleId" class="bcm-input bcm-select" required>
|
|
53
|
+
<option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
|
|
54
|
+
<option v-for="r in ctl.extras.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
|
|
55
|
+
</select>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="bcm-field">
|
|
58
|
+
<label :for="`${id}-facts`">{{ t('statement.facts') }}</label>
|
|
59
|
+
<textarea :id="`${id}-facts`" v-model="d.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-facts-hint`" />
|
|
60
|
+
<p :id="`${id}-facts-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
<template v-else>
|
|
64
|
+
<p role="status">{{ t('moderation.removed') }}</p>
|
|
65
|
+
<h4>{{ t('statement.title') }}</h4>
|
|
66
|
+
<pre class="bcm-facts">{{ d.statement }}</pre>
|
|
67
|
+
</template>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<template v-else-if="d.kind === 'escalate'">
|
|
71
|
+
<template v-if="!d.done">
|
|
72
|
+
<p>{{ t('moderation.escalateLede') }}</p>
|
|
73
|
+
<div class="bcm-field">
|
|
74
|
+
<label :for="`${id}-category`">{{ t('moderation.escalateCategory') }}</label>
|
|
75
|
+
<select :id="`${id}-category`" v-model="d.category" class="bcm-input bcm-select" required>
|
|
76
|
+
<option value="" disabled>{{ t('moderation.escalateCategory') }}</option>
|
|
77
|
+
<option v-for="c in ESCALATION_CATEGORIES" :key="c" :value="c">{{ t(categoryKey(c)) }}</option>
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
<p v-else role="status">{{ t('moderation.escalated') }}</p>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<template v-else>
|
|
85
|
+
<div class="bcm-field">
|
|
86
|
+
<label :for="`${id}-target`">{{ t('moderation.moveTo') }}</label>
|
|
87
|
+
<select :id="`${id}-target`" v-model="d.categoryId" class="bcm-input bcm-select" required>
|
|
88
|
+
<option value="" disabled>{{ t('moderation.moveTo') }}</option>
|
|
89
|
+
<option v-for="c in ctl.extras.moveTargets ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
|
|
90
|
+
</select>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
95
|
+
<div class="bcm-row">
|
|
96
|
+
<span class="bcm-spacer" />
|
|
97
|
+
<button type="button" class="bcm-btn" :disabled="d.sending" @click="emit('close')">{{ d.done ? t('common.close') : t('common.cancel') }}</button>
|
|
98
|
+
<button v-if="!d.done" type="submit" class="bcm-btn bcm-primary" :class="{ 'bcm-danger': d.kind === 'remove' }" :disabled="!canSend">{{ t(titleKey) }}</button>
|
|
99
|
+
</div>
|
|
100
|
+
</form>
|
|
101
|
+
</dialog>
|
|
102
|
+
</template>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
|
|
11
11
|
import {
|
|
12
12
|
COMMUNITY_HANDLE_CHANGE_DAYS,
|
|
13
|
+
adverseOutcomeMessageKey,
|
|
13
14
|
signInLink,
|
|
14
15
|
statementPath,
|
|
15
16
|
type CommunityMessageKey,
|
|
@@ -283,6 +284,8 @@ watch(
|
|
|
283
284
|
<li v-for="r in state.reports" :key="r.caseId">
|
|
284
285
|
<p v-if="categoryKey(r.category)"><strong>{{ t(categoryKey(r.category)!) }}</strong></p>
|
|
285
286
|
<p v-if="caseStatusKey(r.status)">{{ t(caseStatusKey(r.status)!) }}</p>
|
|
287
|
+
<!-- M3 (§3.5, §4.7): an adverse-event case's outcome, where M1 shows a decision. -->
|
|
288
|
+
<p v-if="adverseOutcomeMessageKey(r.outcome)">{{ t(adverseOutcomeMessageKey(r.outcome)!) }}</p>
|
|
286
289
|
<time class="bcm-time" :datetime="r.reportedAt">{{ formatDate(lang, r.reportedAt) }}</time>
|
|
287
290
|
<p v-if="r.decidedAt" class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, r.decidedAt) }) }}</p>
|
|
288
291
|
</li>
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
* A notifier's view of their notice (DSA art. 16(5), 17; m1-contracts.md §7.7 `notices/status`): its status,
|
|
4
4
|
* the statement once decided — never the case's package —, and the appeal while its window is open. The
|
|
5
5
|
* receipt that opened it came in the address's fragment and is gone from the address bar already.
|
|
6
|
+
*
|
|
7
|
+
* M3 (m3-contracts.md §3.5, §4.7): an adverse-event notice the team closed with its outcome reads the outcome in plain
|
|
8
|
+
* words and the redress line (a court or an out-of-court settlement), as the `notice_closed` e-mail does — a case
|
|
9
|
+
* closed by its outcome alone has no decision, so no statement and no appeal; a decided case keeps both.
|
|
6
10
|
*/
|
|
7
11
|
import { computed } from 'vue'
|
|
8
|
-
import { COMMUNITY_APPEAL_MAX, type NoticeStatus } from '@blogmcp/sdk/community'
|
|
12
|
+
import { COMMUNITY_APPEAL_MAX, adverseOutcomeMessageKey, type NoticeStatus } from '@blogmcp/sdk/community'
|
|
9
13
|
import type { AppealDraft } from '../core/panel'
|
|
10
14
|
import { formatDate, isPast } from '../core/format'
|
|
11
15
|
import { knownKey } from '../core/i18n'
|
|
@@ -21,6 +25,10 @@ const id = uid('bcm-notice-status')
|
|
|
21
25
|
const n = computed(() => props.notice)
|
|
22
26
|
const category = computed(() => knownKey(`category.${n.value.category}`))
|
|
23
27
|
const status = computed(() => knownKey(`caseStatus.${n.value.status}`))
|
|
28
|
+
/** M3: the adverse-event outcome's words (null: none, or one this SDK has no words for). */
|
|
29
|
+
const outcome = computed(() => adverseOutcomeMessageKey(n.value.outcome))
|
|
30
|
+
/** Closed by its outcome alone: no decision — nothing to appeal; the redress line says what is left. */
|
|
31
|
+
const outcomeOnly = computed(() => outcome.value !== null && n.value.statement === null && !n.value.appeal.canAppeal && !n.value.appeal.open)
|
|
24
32
|
const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
|
|
25
33
|
const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
|
|
26
34
|
const closed = computed(() => (n.value.appeal.closesAt ? isPast(n.value.appeal.closesAt) : false))
|
|
@@ -47,12 +55,17 @@ const failure = computed(() => {
|
|
|
47
55
|
</dl>
|
|
48
56
|
<p class="bcm-muted">{{ t('notice.submittedAt', { date: formatDate(lang, n.submittedAt) }) }}</p>
|
|
49
57
|
<p v-if="n.decidedAt" class="bcm-muted">{{ t('notice.decidedAt', { date: formatDate(lang, n.decidedAt) }) }}</p>
|
|
58
|
+
<template v-if="outcome">
|
|
59
|
+
<h4>{{ t('notice.outcome') }}</h4>
|
|
60
|
+
<p>{{ t(outcome) }}</p>
|
|
61
|
+
<p v-if="outcomeOnly">{{ t('notice.redress') }}</p>
|
|
62
|
+
</template>
|
|
50
63
|
<template v-if="n.statement">
|
|
51
64
|
<h4>{{ t('notice.statement') }}</h4>
|
|
52
65
|
<pre class="bcm-facts">{{ n.statement }}</pre>
|
|
53
66
|
</template>
|
|
54
67
|
|
|
55
|
-
<section v-if="n.decidedAt" class="bcm-card" :aria-labelledby="`${id}-appeal`">
|
|
68
|
+
<section v-if="n.decidedAt && !outcomeOnly" class="bcm-card" :aria-labelledby="`${id}-appeal`">
|
|
56
69
|
<h4 :id="`${id}-appeal`">{{ t('notice.appealTitle') }}</h4>
|
|
57
70
|
<template v-if="n.appeal.open">
|
|
58
71
|
<p>{{ t('notice.appealOpen') }}</p>
|
|
@@ -7,11 +7,23 @@
|
|
|
7
7
|
* notices), and (M2 T10) a reply's or a mention's message on its article — the host's `articlePath` with the slug
|
|
8
8
|
* and the message's anchor (`#bcm-msg-<seq>`); a host without `articlePath`, or a row without a slug or seq (an
|
|
9
9
|
* article no longer live, an older kb-api), keeps the sentence unlinked. A visitor gets the sign-in sentence.
|
|
10
|
+
*
|
|
11
|
+
* M3 (m3-contracts.md §4.3): `topic_update` (a followed conversation's new message) links like a reply; `category_topic`
|
|
12
|
+
* (a followed category's new topic) links the topic's page (`topicPath`, the forum's default under the site base) —
|
|
13
|
+
* and, of the „Články" category, the new discussion's message on its article (kb-api names the article there; its own
|
|
14
|
+
* sentence, notificationItemMessageKey).
|
|
15
|
+
*
|
|
16
|
+
* M3 FX (the review of wave 1, R20): kb-api names each row's conversation kind (`conversationKind`). A topic's `reply`,
|
|
17
|
+
* `mention` and `topic_update` link their message on the topic's page (topicMessageLink); an article's link it on the
|
|
18
|
+
* article while kb-api names the article — and a row of an article no longer readable stays an article's row: unlinked,
|
|
19
|
+
* never a topic's link, and a „Články" `category_topic` keeps its sentence of a discussion under an article.
|
|
10
20
|
*/
|
|
11
21
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
12
22
|
import {
|
|
13
23
|
communityMessageLink,
|
|
14
|
-
|
|
24
|
+
topicMessageLink,
|
|
25
|
+
topicPath,
|
|
26
|
+
notificationItemMessageKey,
|
|
15
27
|
signInLink,
|
|
16
28
|
statementPath,
|
|
17
29
|
type CommunityMessageKey,
|
|
@@ -62,7 +74,9 @@ function signInHref(): string | null {
|
|
|
62
74
|
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
|
|
77
|
+
/** The row's sentence; a „Články" row whose article is no longer readable is still a discussion under an article (R20). */
|
|
78
|
+
const sentence = (n: NotificationItem): CommunityMessageKey | null =>
|
|
79
|
+
n.code === 'category_topic' && n.conversationKind === 'article' ? 'notification.category_topic.article' : notificationItemMessageKey(n)
|
|
66
80
|
|
|
67
81
|
/** The page a notification's subject is on, with its link text; null when the host has none for it. */
|
|
68
82
|
function subjectOf(n: NotificationItem): { href: string; key: CommunityMessageKey } | null {
|
|
@@ -70,7 +84,27 @@ function subjectOf(n: NotificationItem): { href: string; key: CommunityMessageKe
|
|
|
70
84
|
const href = statementPath(routes.value.statementPath, n.decisionId)
|
|
71
85
|
return href ? { href, key: 'sanction.statement' } : null
|
|
72
86
|
}
|
|
73
|
-
|
|
87
|
+
// M3 (§4.3): `category_topic` is a followed category's new topic — its page — or, of the „Články" category, a
|
|
88
|
+
// discussion new to its listing: kb-api names the article then, and the row links its message there (the wave 2
|
|
89
|
+
// integration; a row whose article is no longer readable names none and reads as a topic's).
|
|
90
|
+
if (n.code === 'category_topic') {
|
|
91
|
+
// An article's row (kb-api names its kind; an older kb-api only its article): its message on the article while the
|
|
92
|
+
// article is readable — else unlinked, never a topic's page.
|
|
93
|
+
if (n.articleId || n.conversationKind === 'article') {
|
|
94
|
+
const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
|
|
95
|
+
return href ? { href, key: 'centre.openMessage' } : null
|
|
96
|
+
}
|
|
97
|
+
const href = typeof n.conversationId === 'string' ? topicPath(routes.value.topicPath, n.conversationId) : null
|
|
98
|
+
return href ? { href, key: 'topic.open' } : null
|
|
99
|
+
}
|
|
100
|
+
// A reply, a mention or (M3) a followed conversation's new message: a topic's links its message on the topic's page
|
|
101
|
+
// (R20: kb-api names the kind), an article's its message on the article while kb-api names the article (M2 T10) — an
|
|
102
|
+
// article no longer readable names none, and its row stays unlinked.
|
|
103
|
+
if (n.code === 'reply' || n.code === 'mention' || n.code === 'topic_update') {
|
|
104
|
+
if (n.conversationKind === 'topic') {
|
|
105
|
+
const href = typeof n.conversationId === 'string' && typeof n.seq === 'number' ? topicMessageLink(routes.value.topicPath, n.conversationId, n.seq) : null
|
|
106
|
+
return href ? { href, key: 'centre.openMessage' } : null
|
|
107
|
+
}
|
|
74
108
|
const href = communityMessageLink(routes.value.articlePath, n.articleSlug, n.seq)
|
|
75
109
|
return href ? { href, key: 'centre.openMessage' } : null
|
|
76
110
|
}
|
package/src/ui/ProfileView.vue
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* `<blogmcp-profile handle>`: a member's public profile (m2-contracts.md §3.2, §3.10) — the name and avatar, since
|
|
4
|
-
* when they are a member (a month), the bio as text, and the trust level as its label („Nový člen" … „
|
|
5
|
-
* never its number.
|
|
6
|
-
*
|
|
4
|
+
* when they are a member (a month), the bio as text, and the trust level as its label („Nový člen" … „Důvěryhodný
|
|
5
|
+
* člen", M3 D-M3-7), never its number. M3 (m3-contracts.md §3.5, §4.5): „Moderátor" with the categories the member
|
|
6
|
+
* moderates now — an appointment, shown to forum members only (kb-api sends `moderates` to them alone). An unknown
|
|
7
|
+
* handle reads one line; a visitor gets the sign-in sentence (profiles are for signed-in readers, kb-api's rule).
|
|
7
8
|
*/
|
|
8
9
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
9
|
-
import { signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
10
|
+
import { categoryPath, signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
11
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
10
12
|
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
11
13
|
import { formatMonth } from '../core/format'
|
|
12
14
|
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
@@ -48,6 +50,7 @@ watch(
|
|
|
48
50
|
onBeforeUnmount(() => ctl?.dispose())
|
|
49
51
|
|
|
50
52
|
const since = computed(() => (state?.memberSince ? formatMonth(lang.value, state.memberSince) : ''))
|
|
53
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
|
|
51
54
|
const failure = computed(() => {
|
|
52
55
|
const f = state?.failure
|
|
53
56
|
if (!f) return ''
|
|
@@ -81,6 +84,14 @@ function signInHref(): string | null {
|
|
|
81
84
|
</div>
|
|
82
85
|
</header>
|
|
83
86
|
<p v-if="state.trustKey"><span class="bcm-badge">{{ t(state.trustKey) }}</span></p>
|
|
87
|
+
<p v-if="state.moderates.length > 0" class="bcm-row">
|
|
88
|
+
<span class="bcm-badge bcm-badge-team">{{ t('role.moderator') }}</span>
|
|
89
|
+
<span>{{ t('profile.moderates') }}</span>
|
|
90
|
+
<template v-for="c in state.moderates" :key="c.slug">
|
|
91
|
+
<a v-if="categoryPath(routes.categoryPath, c.slug)" :href="categoryPath(routes.categoryPath, c.slug) ?? undefined">{{ c.name }}</a>
|
|
92
|
+
<span v-else>{{ c.name }}</span>
|
|
93
|
+
</template>
|
|
94
|
+
</p>
|
|
84
95
|
<p v-if="since" class="bcm-muted">{{ t('profile.memberSince', { date: since }) }}</p>
|
|
85
96
|
<section v-if="state.bio" class="bcm-section" :aria-labelledby="`${id}-bio`">
|
|
86
97
|
<h3 :id="`${id}-bio`">{{ t('profile.bio') }}</h3>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One row of a category's list (m3-contracts.md §3.2): a topic — its title (a link to the topic's page; a missing title
|
|
4
|
+
* reads „Smazané téma" only for a deleted opening post, the withheld wording otherwise), pinned, closed or archived,
|
|
5
|
+
* „čeká na kontrolu" on the viewer's own topic waiting for review, its author, the messages everyone is shown and its
|
|
6
|
+
* last activity — or in „Články" an article's discussion: the article's title — M3 FX (R20): a link to the article's
|
|
7
|
+
* page (the host's `articlePath` with kb-api's `articleSlug`; text where the host gives no article template) — with
|
|
8
|
+
* its follow button.
|
|
9
|
+
*/
|
|
10
|
+
import { computed } from 'vue'
|
|
11
|
+
import { communityArticleLink, topicPath, topicTitleOf, type CommunityMessageKey, type KnownTopicItem } from '@blogmcp/sdk/community'
|
|
12
|
+
import type { FollowController } from '../core/follow'
|
|
13
|
+
import { formatDate } from '../core/format'
|
|
14
|
+
import FollowToggle from './FollowToggle.vue'
|
|
15
|
+
import { useText } from './context'
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{ item: KnownTopicItem; topicHref: string; articleHref?: string | null; follow?: FollowController | null }>()
|
|
18
|
+
const { t, lang } = useText()
|
|
19
|
+
|
|
20
|
+
const title = computed(() => (props.item.kind === 'topic' ? topicTitleOf(props.item) : null))
|
|
21
|
+
const titleText = computed(() => {
|
|
22
|
+
const ti = title.value
|
|
23
|
+
if (!ti) return ''
|
|
24
|
+
return ti.kind === 'shown' ? ti.text : t(ti.kind === 'deleted' ? 'topic.deleted' : 'topic.withheld')
|
|
25
|
+
})
|
|
26
|
+
const href = computed(() => (props.item.kind === 'topic' ? topicPath(props.topicHref, props.item.conversationId) : null))
|
|
27
|
+
/** R20: a „Články" row's article page (null without the host's article template, or for an older kb-api's row without a slug). */
|
|
28
|
+
const articleLink = computed(() => (props.item.kind === 'article' ? communityArticleLink(props.articleHref, props.item.articleSlug) : null))
|
|
29
|
+
const stateKey = computed<CommunityMessageKey | null>(() => {
|
|
30
|
+
if (props.item.kind !== 'topic') return null
|
|
31
|
+
return props.item.state === 'locked' ? 'topic.locked' : props.item.state === 'archived' ? 'topic.archived' : null
|
|
32
|
+
})
|
|
33
|
+
const authorName = computed(() => {
|
|
34
|
+
if (props.item.kind !== 'topic') return null
|
|
35
|
+
const a = props.item.author
|
|
36
|
+
if (a.kind === 'member') return 'displayName' in a && a.displayName ? a.displayName : t('author.deleted')
|
|
37
|
+
if (a.kind === 'staff' && 'displayName' in a) return a.displayName
|
|
38
|
+
return null
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<li class="bcm-topic-row">
|
|
44
|
+
<template v-if="item.kind === 'topic'">
|
|
45
|
+
<p class="bcm-topic-title">
|
|
46
|
+
<a v-if="href" :href="href" :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</a>
|
|
47
|
+
<span v-else :class="{ 'bcm-placeholder': title?.kind !== 'shown' }">{{ titleText }}</span>
|
|
48
|
+
</p>
|
|
49
|
+
<p class="bcm-row bcm-muted">
|
|
50
|
+
<span v-if="item.pinned" class="bcm-badge">{{ t('topic.pinned') }}</span>
|
|
51
|
+
<span v-if="stateKey" class="bcm-badge">{{ t(stateKey) }}</span>
|
|
52
|
+
<span v-if="item.openingState === 'held' && title?.kind === 'shown'" class="bcm-badge">{{ t('state.held') }}</span>
|
|
53
|
+
<span v-if="authorName">{{ authorName }}</span>
|
|
54
|
+
<span>{{ t('topic.count', { count: item.count }) }}</span>
|
|
55
|
+
<time :datetime="item.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, item.lastActivityAt) }) }}</time>
|
|
56
|
+
</p>
|
|
57
|
+
</template>
|
|
58
|
+
<template v-else>
|
|
59
|
+
<p class="bcm-topic-title">
|
|
60
|
+
<a v-if="articleLink" :href="articleLink">{{ item.articleTitle ?? t('search.inArticle') }}</a>
|
|
61
|
+
<span v-else>{{ item.articleTitle ?? t('search.inArticle') }}</span>
|
|
62
|
+
</p>
|
|
63
|
+
<p class="bcm-row bcm-muted">
|
|
64
|
+
<span>{{ t('discussion.count', { count: item.count }) }}</span>
|
|
65
|
+
<time :datetime="item.lastActivityAt">{{ t('forum.lastActivity', { date: formatDate(lang, item.lastActivityAt) }) }}</time>
|
|
66
|
+
<FollowToggle v-if="follow" :ctl="follow" />
|
|
67
|
+
</p>
|
|
68
|
+
</template>
|
|
69
|
+
</li>
|
|
70
|
+
</template>
|