@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.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. 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>
@@ -1,18 +1,24 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * A member's text, as text: runs of text nodes, and the web addresses in it as links that went through
4
- * sanitizeUrl (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab. Never HTML.
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 { linkify, UGC_LINK_REL } from '../core/text'
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(() => linkify(props.text ?? ''))
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>