@blogmcp/community 0.0.0-stage → 0.2.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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
@@ -0,0 +1,334 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A refused age band, per element: kb-api keeps nothing of it (§7.4.2), so the element does, for its whole life —
4
+ * a discussion started again (another language) and the element put back by its host included.
5
+ */
6
+ const refusedAges = new WeakMap<object, number>()
7
+ </script>
8
+
9
+ <script setup lang="ts">
10
+ /**
11
+ * `<blogmcp-discussion>`: the discussion of one article (m1-contracts.md §10.4). Lives in a shadow root; talks
12
+ * only to the host's relative `api-base` through the SDK client; renders nothing when discussions are off,
13
+ * and one sentence while the discussion takes no writes yet.
14
+ */
15
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
16
+ import { communityMessageAnchor, communityMessageSeqFromHash, signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
17
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
18
+ import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
19
+ import { createDiscussionController, type DiscussionController } from '../core/discussion'
20
+ import type { DiscussionRealtime } from '../core/realtime'
21
+ import { formatDate } from '../core/format'
22
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
23
+ import { failureText } from '../core/outcomes'
24
+ import ConfirmDialog from './ConfirmDialog.vue'
25
+ import MessageComposer from './MessageComposer.vue'
26
+ import MessageItem from './MessageItem.vue'
27
+ import OnboardingSteps from './OnboardingSteps.vue'
28
+ import ReactionBar from './ReactionBar.vue'
29
+ import ReportDialog from './ReportDialog.vue'
30
+ import { provideText, uid } from './context'
31
+ import { provideDiscussion } from './discussion-context'
32
+
33
+ defineOptions({ inheritAttrs: false })
34
+
35
+ const props = defineProps<{
36
+ articleId?: string
37
+ /** The host's relative base (default `${siteBase}/api/community`). */
38
+ apiBase?: string
39
+ /** The path the site is served under (`/blog` on a managed blog): its proxy and its pages, as the defaults. */
40
+ siteBase?: string
41
+ lang?: string
42
+ /** The site's sign-in page; the current path goes in its fragment. */
43
+ signInHref?: string
44
+ /** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
45
+ routes?: string | Record<string, unknown>
46
+ /** Head poll interval in ms (default 20 000; 0 = off). */
47
+ pollMs?: number
48
+ /** A replacement transport (a property, never an attribute): tests, a signing transport later. */
49
+ transport?: CommunityTransport
50
+ /**
51
+ * M2: live updates — the host's session factory (`createRealtimeSession` of `@blogmcp/sdk/community/realtime`), a
52
+ * property, never an attribute. Absent: the head poll.
53
+ */
54
+ realtime?: DiscussionRealtime
55
+ }>()
56
+
57
+ const host = useHost()
58
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
59
+ const { t, text } = provideText(lang)
60
+ watch(
61
+ () => props.lang,
62
+ (v) => {
63
+ lang.value = pickLang(v, documentLang())
64
+ },
65
+ )
66
+
67
+ const headingId = uid('bcm-discussion')
68
+ const heading = ref<HTMLElement | null>(null)
69
+ const generation = ref(0)
70
+ const current = shallowRef<DiscussionController | null>(null)
71
+ const siteBase = computed(() => siteBaseFor(props.siteBase))
72
+
73
+ function create(): DiscussionController | null {
74
+ const articleId = props.articleId?.trim()
75
+ if (!articleId) {
76
+ warnOnce('article-id', '[blogmcp-community] <blogmcp-discussion> needs an article-id')
77
+ return null
78
+ }
79
+ if (siteBase.value === null) return null
80
+ const client = communityClientFor(props.apiBase, props.transport, siteBase.value)
81
+ if (!client) return null
82
+ return createDiscussionController({
83
+ client,
84
+ articleId,
85
+ lang: lang.value,
86
+ pollMs: props.pollMs,
87
+ realtime: typeof props.realtime === 'function' ? props.realtime : null,
88
+ refusedAge: (host && refusedAges.get(host)) ?? null,
89
+ onAgeRefused: (minAge) => {
90
+ if (host) refusedAges.set(host, minAge)
91
+ },
92
+ onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
93
+ })
94
+ }
95
+
96
+ const opened = reactive(new Set<number>())
97
+ const originals = reactive(new Set<number>())
98
+ const allReplies = reactive(new Set<number>())
99
+ const deleting = ref<number | null>(null)
100
+
101
+ const parsedRoutes = computed(() => parseRoutes(props.routes))
102
+ const routes = computed(() => routesWithDefaults(parsedRoutes.value, current.value?.state.me?.links, siteBase.value ?? ''))
103
+
104
+ provideDiscussion({
105
+ get ctl() {
106
+ return current.value as DiscussionController
107
+ },
108
+ routes,
109
+ opened,
110
+ originals,
111
+ allReplies,
112
+ focusHeading: () => heading.value,
113
+ deleteMessage: (seq) => {
114
+ deleting.value = seq
115
+ },
116
+ })
117
+
118
+ let observer: IntersectionObserver | null = null
119
+ const section = ref<HTMLElement | null>(null)
120
+ /** The page's `#bcm-msg-<seq>` was looked for once this discussion's first page was in. */
121
+ let anchored = false
122
+
123
+ /**
124
+ * M2 T10: a link to one message (the notification centre's: `<article>#bcm-msg-<seq>`). The rows live in this shadow
125
+ * root, so the browser cannot scroll to the fragment: once the first page is in, the row is scrolled into view — a
126
+ * reply hidden behind „more replies" is shown first. A message beyond the first page is not looked for.
127
+ */
128
+ async function scrollToAnchor(): Promise<void> {
129
+ if (anchored || typeof location === 'undefined') return
130
+ anchored = true
131
+ const seq = communityMessageSeqFromHash(location.hash)
132
+ const id = seq === null ? null : communityMessageAnchor(seq)
133
+ if (seq === null || id === null) return
134
+ const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
135
+ if (parent) allReplies.add(parent.seq)
136
+ await nextTick()
137
+ const row = section.value?.querySelector<HTMLElement>(`#${id}`)
138
+ if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
139
+ }
140
+
141
+ function start(): void {
142
+ anchored = false
143
+ current.value?.dispose()
144
+ opened.clear()
145
+ originals.clear()
146
+ allReplies.clear()
147
+ deleting.value = null
148
+ current.value = create()
149
+ generation.value += 1
150
+ void current.value?.start()
151
+ }
152
+
153
+ onMounted(() => {
154
+ start()
155
+ if (host && typeof IntersectionObserver !== 'undefined') {
156
+ observer = new IntersectionObserver((entries) => {
157
+ const e = entries[entries.length - 1]
158
+ if (e) current.value?.setInView(e.isIntersecting)
159
+ }, { rootMargin: '200px' })
160
+ observer.observe(host)
161
+ }
162
+ })
163
+ // Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
164
+ watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
165
+ onBeforeUnmount(() => {
166
+ observer?.disconnect()
167
+ current.value?.dispose()
168
+ current.value = null
169
+ })
170
+
171
+ const ctl = computed(() => current.value)
172
+ const state = computed(() => current.value?.state ?? null)
173
+ watch(
174
+ () => state.value?.phase,
175
+ (phase) => {
176
+ if (phase === 'ready') void scrollToAnchor()
177
+ },
178
+ )
179
+ const mode = computed(() => current.value?.mode() ?? 'pending')
180
+ const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
181
+ // Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
182
+ // (the onboarding's button the focus was on is gone).
183
+ watch(mode, (now, before) => {
184
+ if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
185
+ if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
186
+ })
187
+ const needKey = computed(() => {
188
+ const n = current.value?.needs()
189
+ return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
190
+ })
191
+
192
+ function signInHref(): string | null {
193
+ return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
194
+ }
195
+
196
+ function sanctionText(r: DiscussionRestriction): string {
197
+ const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
198
+ if (r.kind === 'ban') return t('sanction.ban')
199
+ if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
200
+ return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
201
+ }
202
+
203
+ const loadFailure = computed(() => {
204
+ const f = state.value?.failure
205
+ if (!f) return ''
206
+ const r = failureText(f, lang.value)
207
+ return t(r.key, r.params)
208
+ })
209
+
210
+ // One live region, present from the start; the same outcome twice is announced twice (cleared in between).
211
+ const liveText = ref('')
212
+ watch(
213
+ () => state.value?.announceId,
214
+ () => {
215
+ liveText.value = ''
216
+ const next = text(state.value?.announcement)
217
+ void nextTick(() => {
218
+ liveText.value = next
219
+ })
220
+ },
221
+ )
222
+
223
+ async function confirmDelete(): Promise<void> {
224
+ const seq = deleting.value
225
+ if (seq === null || !current.value) return
226
+ await current.value.remove(seq)
227
+ deleting.value = null
228
+ }
229
+ </script>
230
+
231
+ <template>
232
+ <section v-if="ctl && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
233
+ <header class="bcm-head">
234
+ <h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
235
+ <span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
236
+ </header>
237
+
238
+ <p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('discussion.loading') }}</p>
239
+
240
+ <div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
241
+ <span>{{ loadFailure }}</span>
242
+ <button type="button" class="bcm-btn" @click="ctl.refresh()">{{ t('common.retry') }}</button>
243
+ </div>
244
+
245
+ <template v-else>
246
+ <ReactionBar
247
+ :reaction-set="state.thread.reactionSet"
248
+ :summary="state.thread.articleReactions"
249
+ :interactive="ctl.canReact()"
250
+ :busy="!!state.busy['react:article']"
251
+ @react="(emoji: string) => ctl && ctl.react({ articleId: state!.thread.articleId ?? articleId ?? '' }, emoji)"
252
+ />
253
+
254
+ <div v-if="state.newMessages" class="bcm-banner" role="status">
255
+ <span>{{ t('discussion.newMessages') }}</span>
256
+ <button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
257
+ </div>
258
+
259
+ <p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
260
+
261
+ <p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
262
+ <ol v-else class="bcm-thread">
263
+ <MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
264
+ </ol>
265
+
266
+ <div v-if="state.thread.nextCursor !== null" class="bcm-row">
267
+ <button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
268
+ </div>
269
+
270
+ <div class="bcm-section">
271
+ <p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
272
+ <MessageComposer
273
+ v-if="mode === 'write'"
274
+ ref="composer"
275
+ kind="new"
276
+ :draft="state.composer"
277
+ :wait="ctl.waitLeft(state.composer)"
278
+ :rules-href="routes.rulesPath"
279
+ :mentions-max="ctl.mentionCap()"
280
+ @submit="ctl.submit()"
281
+ @confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
282
+ @confirmed="ctl.sendConfirmed(state.composer)"
283
+ />
284
+ <div v-else-if="mode === 'signIn'" class="bcm-row">
285
+ <p>{{ t('discussion.signInPrompt') }}</p>
286
+ <a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
287
+ </div>
288
+ <template v-else-if="mode === 'onboarding'">
289
+ <OnboardingSteps v-if="ctl.onboarding.value" :model="ctl.onboarding.value" :rules-href="routes.rulesPath" @cancel="ctl.cancelOnboarding()" />
290
+ <div v-else class="bcm-row">
291
+ <p>{{ t(needKey) }}</p>
292
+ <button type="button" class="bcm-btn bcm-primary" @click="ctl.beginOnboarding()">{{ t('onboarding.continue') }}</button>
293
+ </div>
294
+ </template>
295
+ <p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
296
+ <div v-else-if="mode === 'sanction'" class="bcm-section">
297
+ <p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
298
+ {{ sanctionText(r) }}
299
+ <a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
300
+ </p>
301
+ </div>
302
+ <p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
303
+ <p v-else-if="mode === 'braked'" class="bcm-note" role="status">{{ t('state.braked') }}</p>
304
+ <p v-else-if="mode === 'restricted'" class="bcm-note">
305
+ {{ t('error.restricted') }}
306
+ <a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
307
+ </p>
308
+ </div>
309
+ </template>
310
+
311
+ <p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
312
+
313
+ <ReportDialog
314
+ v-if="state.report"
315
+ :report="state.report"
316
+ :my-community-href="routes.myCommunityPath"
317
+ :rules-href="routes.rulesPath"
318
+ :fallback-focus="() => heading"
319
+ @submit="ctl.submitReport()"
320
+ @close="ctl.closeReport()"
321
+ />
322
+ <ConfirmDialog
323
+ v-if="deleting !== null"
324
+ title="discussion.deleteTitle"
325
+ explain="discussion.deleteExplain"
326
+ confirm="discussion.deleteConfirm"
327
+ danger
328
+ :busy="!!state.busy[`delete:${deleting}`]"
329
+ :fallback-focus="() => heading"
330
+ @confirm="confirmDelete()"
331
+ @cancel="deleting = null"
332
+ />
333
+ </section>
334
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ /**
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.
6
+ */
7
+ import { computed } from 'vue'
8
+ import { profilePath as profilePathOf } from '@blogmcp/sdk/community'
9
+ import { linkifyWithMentions, UGC_LINK_REL } from '../core/text'
10
+
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
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <template v-for="(s, i) in segments" :key="i">
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>
22
+ <template v-else>{{ s.text }}</template>
23
+ </template>
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>