@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,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>
|
package/src/ui/LinkedText.vue
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* A member's text, as text: runs of text nodes,
|
|
4
|
-
*
|
|
3
|
+
* A member's text, as text: runs of text nodes, the web addresses in it as links that went through sanitizeUrl
|
|
4
|
+
* (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab — and, with `profilePath` (M2), each `@handle`
|
|
5
|
+
* as a link to that member's profile page on this site. Never HTML.
|
|
5
6
|
*/
|
|
6
7
|
import { computed } from 'vue'
|
|
7
|
-
import {
|
|
8
|
+
import { profilePath as profilePathOf } from '@blogmcp/sdk/community'
|
|
9
|
+
import { linkifyWithMentions, UGC_LINK_REL } from '../core/text'
|
|
8
10
|
|
|
9
|
-
const props = defineProps<{ text: string | null }>()
|
|
10
|
-
const segments = computed(() =>
|
|
11
|
+
const props = defineProps<{ text: string | null; profilePath?: string | null }>()
|
|
12
|
+
const segments = computed(() => {
|
|
13
|
+
const template = props.profilePath
|
|
14
|
+
return linkifyWithMentions(props.text ?? '', template ? (handle: string) => profilePathOf(template, handle) : null)
|
|
15
|
+
})
|
|
11
16
|
</script>
|
|
12
17
|
|
|
13
18
|
<template>
|
|
14
19
|
<template v-for="(s, i) in segments" :key="i">
|
|
15
20
|
<a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
|
|
21
|
+
<a v-else-if="s.kind === 'mention'" class="bcm-mention" :href="s.href">{{ s.text }}</a>
|
|
16
22
|
<template v-else>{{ s.text }}</template>
|
|
17
23
|
</template>
|
|
18
24
|
</template>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-member-settings>`: the member's notification settings (m2-contracts.md §3.3, §3.4, §3.10) — the six
|
|
4
|
+
* switches saved one by one, this browser's push (only while the module offers it: „Zapnout upozornění" on the
|
|
5
|
+
* member's click, „Vypnout", the sentence about what a notification carries, „Otevřít v prohlížeči" in Facebook's or
|
|
6
|
+
* Instagram's in-app browser, the install guide on iOS outside the home screen), and the digest's one-click
|
|
7
|
+
* unsubscribe when the e-mail's link (`#u=`) brought the member here — which works without a session too.
|
|
8
|
+
*/
|
|
9
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
|
|
10
|
+
import { signInLink, type CommunityMessageKey, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
11
|
+
import { isSitePath } from '../core/attrs'
|
|
12
|
+
import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
|
|
13
|
+
import { digestTokenForElement } from '../core/digest-token'
|
|
14
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
15
|
+
import { failureText } from '../core/outcomes'
|
|
16
|
+
import { createMemberSettings, SETTING_KEYS, type MemberSettingsController, type SettingKey } from '../core/settings'
|
|
17
|
+
import { provideText, uid } from './context'
|
|
18
|
+
|
|
19
|
+
defineOptions({ inheritAttrs: false })
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
apiBase?: string
|
|
23
|
+
siteBase?: string
|
|
24
|
+
lang?: string
|
|
25
|
+
/** The host's service worker script (default `<site-base>/sw.js`): served from @blogmcp/sdk/community/sw. */
|
|
26
|
+
swPath?: string
|
|
27
|
+
routes?: string | Record<string, unknown>
|
|
28
|
+
signInHref?: string
|
|
29
|
+
transport?: CommunityTransport
|
|
30
|
+
}>()
|
|
31
|
+
|
|
32
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
33
|
+
const { t, text } = provideText(lang)
|
|
34
|
+
const id = uid('bcm-settings')
|
|
35
|
+
const host = useHost()
|
|
36
|
+
|
|
37
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
38
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
39
|
+
|
|
40
|
+
function swPathOf(value: string | undefined): string | null {
|
|
41
|
+
if (value === undefined || value === '') return `${siteBase ?? ''}/sw.js`
|
|
42
|
+
if (isSitePath(value)) return value
|
|
43
|
+
warnOnce('sw-path', '[blogmcp-community] sw-path must be a path on this site (e.g. "/blog/sw.js"): the service worker is the site’s own')
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
// The digest's token is read and cleared from the address first, whatever follows: a misconfigured element (no
|
|
47
|
+
// client, a refused sw-path) renders nothing, but never leaves the member's capability in the URL or its history.
|
|
48
|
+
const digestToken = digestTokenForElement(host)
|
|
49
|
+
const swPath = swPathOf(props.swPath)
|
|
50
|
+
const ctl: MemberSettingsController | null = client && swPath ? createMemberSettings({ client, swPath, digestToken }) : null
|
|
51
|
+
const state = ctl?.state ?? null
|
|
52
|
+
|
|
53
|
+
onMounted(() => void ctl?.load())
|
|
54
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
55
|
+
|
|
56
|
+
const LABELS: Readonly<Record<SettingKey, CommunityMessageKey>> = Object.freeze({
|
|
57
|
+
notifyReply: 'settings.notifyReply',
|
|
58
|
+
notifyMention: 'settings.notifyMention',
|
|
59
|
+
notifyTopic: 'settings.notifyTopic',
|
|
60
|
+
pushEnabled: 'settings.pushEnabled',
|
|
61
|
+
digestEmail: 'settings.digestEmail',
|
|
62
|
+
quietHours: 'settings.quietHours',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const failure = computed(() => {
|
|
66
|
+
const f = state?.failure
|
|
67
|
+
if (!f) return ''
|
|
68
|
+
const r = failureText(f, lang.value)
|
|
69
|
+
return t(r.key, r.params)
|
|
70
|
+
})
|
|
71
|
+
const pushFailure = computed(() => {
|
|
72
|
+
const f = state?.push.failure
|
|
73
|
+
if (!f) return ''
|
|
74
|
+
const r = failureText(f, lang.value)
|
|
75
|
+
return t(r.key, r.params)
|
|
76
|
+
})
|
|
77
|
+
const digestFailure = computed(() => {
|
|
78
|
+
const f = state?.digest.failure
|
|
79
|
+
if (!f) return ''
|
|
80
|
+
const r = failureText(f, lang.value)
|
|
81
|
+
return t(r.key, r.params)
|
|
82
|
+
})
|
|
83
|
+
const pageUrl = computed(() => (typeof location === 'undefined' ? '' : location.href))
|
|
84
|
+
|
|
85
|
+
function signInHref(): string | null {
|
|
86
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onSwitch(key: SettingKey, e: Event): void {
|
|
90
|
+
void ctl?.toggle(key, (e.target as HTMLInputElement).checked)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const liveText = ref('')
|
|
94
|
+
watch(
|
|
95
|
+
() => state?.announceId,
|
|
96
|
+
() => {
|
|
97
|
+
liveText.value = ''
|
|
98
|
+
const next = text(state?.announcement)
|
|
99
|
+
void nextTick(() => {
|
|
100
|
+
liveText.value = next
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<section v-if="ctl && state" class="bcm bcm-settings" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
108
|
+
<h2 :id="`${id}-title`">{{ t('settings.title') }}</h2>
|
|
109
|
+
|
|
110
|
+
<!-- the digest's unsubscribe link: offered whoever is signed in (the link is the authorisation) -->
|
|
111
|
+
<section v-if="state.digest.offered" class="bcm-card" :aria-labelledby="`${id}-digest`">
|
|
112
|
+
<h3 :id="`${id}-digest`">{{ t('settings.digestEmail') }}</h3>
|
|
113
|
+
<p v-if="state.digest.outcome === 'unsubscribed'" role="status">{{ t('settings.digest.unsubscribed') }}</p>
|
|
114
|
+
<p v-else-if="state.digest.outcome === 'staleLink'" class="bcm-note" role="alert">{{ t('settings.digest.staleLink') }}</p>
|
|
115
|
+
<template v-else>
|
|
116
|
+
<p>{{ t('settings.digest.prompt') }}</p>
|
|
117
|
+
<p v-if="digestFailure" class="bcm-error" role="alert">{{ digestFailure }}</p>
|
|
118
|
+
<div class="bcm-row">
|
|
119
|
+
<button type="button" class="bcm-btn bcm-primary" :disabled="state.digest.sending" @click="ctl.unsubscribeDigest()">{{ t('settings.digest.confirm') }}</button>
|
|
120
|
+
</div>
|
|
121
|
+
</template>
|
|
122
|
+
</section>
|
|
123
|
+
|
|
124
|
+
<p v-if="state.phase === 'loading' || state.phase === 'idle'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
125
|
+
<div v-else-if="state.phase === 'signIn'" class="bcm-row">
|
|
126
|
+
<p>{{ t('settings.signIn') }}</p>
|
|
127
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
128
|
+
</div>
|
|
129
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
130
|
+
<span>{{ failure }}</span>
|
|
131
|
+
<button type="button" class="bcm-btn" @click="ctl.load()">{{ t('common.retry') }}</button>
|
|
132
|
+
</div>
|
|
133
|
+
<template v-else-if="state.settings">
|
|
134
|
+
<ul class="bcm-switches">
|
|
135
|
+
<li v-for="key in SETTING_KEYS" :key="key">
|
|
136
|
+
<label class="bcm-check">
|
|
137
|
+
<input type="checkbox" :name="key" :checked="state.settings[key]" :disabled="!!state.saving[key]" @change="onSwitch(key, $event)" />
|
|
138
|
+
<span>{{ t(LABELS[key]) }}</span>
|
|
139
|
+
</label>
|
|
140
|
+
</li>
|
|
141
|
+
</ul>
|
|
142
|
+
|
|
143
|
+
<!-- this browser's push -->
|
|
144
|
+
<section v-if="ctl.pushShown()" class="bcm-card" :aria-labelledby="`${id}-push`">
|
|
145
|
+
<h3 :id="`${id}-push`">{{ t('settings.pushEnabled') }}</h3>
|
|
146
|
+
<p class="bcm-hint">{{ t('settings.push.carries') }}</p>
|
|
147
|
+
<template v-if="state.push.support === 'inApp'">
|
|
148
|
+
<p>{{ t('settings.push.inAppExplain') }}</p>
|
|
149
|
+
<p><a :href="pageUrl" target="_blank" rel="noopener">{{ t('settings.push.openInBrowser') }}</a></p>
|
|
150
|
+
<p class="bcm-url">{{ pageUrl }}</p>
|
|
151
|
+
</template>
|
|
152
|
+
<p v-else-if="state.push.support === 'iosInstall'">{{ t('settings.push.iosGuide') }}</p>
|
|
153
|
+
<p v-else-if="state.push.support === 'unsupported'">{{ t('settings.push.unsupported') }}</p>
|
|
154
|
+
<template v-else>
|
|
155
|
+
<p v-if="state.push.note === 'reset'" class="bcm-note">{{ t('settings.push.reset') }}</p>
|
|
156
|
+
<p v-else-if="state.push.note === 'denied'" class="bcm-note" role="alert">{{ t('settings.push.denied') }}</p>
|
|
157
|
+
<p v-else-if="state.push.note === 'refused'" class="bcm-note" role="alert">{{ t('settings.push.refused') }}</p>
|
|
158
|
+
<p v-if="pushFailure && state.push.note === null" class="bcm-error" role="alert">{{ pushFailure }}</p>
|
|
159
|
+
<div class="bcm-row">
|
|
160
|
+
<button v-if="state.push.status === 'on'" type="button" class="bcm-btn" @click="ctl.disablePush()">{{ t('settings.push.disable') }}</button>
|
|
161
|
+
<button
|
|
162
|
+
v-else
|
|
163
|
+
type="button"
|
|
164
|
+
class="bcm-btn bcm-primary"
|
|
165
|
+
:disabled="state.push.status === 'busy' || state.push.status === 'checking'"
|
|
166
|
+
@click="ctl.enablePush()"
|
|
167
|
+
>{{ t('settings.push.enable') }}</button>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
170
|
+
</section>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
174
|
+
</section>
|
|
175
|
+
</template>
|
|
@@ -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>
|