@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.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +242 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A statement of reasons (DSA art. 17(3); m1-contracts.md §7.3 `me/decisions/get`) and its redress: the
|
|
4
|
+
* stored fields as text — the facts in a `<pre class="bcm-facts">` —, the subject as its author may see it,
|
|
5
|
+
* the whole statement kb-api rendered, and the appeal within its window (or the open appeal's state).
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import { COMMUNITY_APPEAL_MAX, type Statement } from '@blogmcp/sdk/community'
|
|
9
|
+
import type { AppealDraft } from '../core/panel'
|
|
10
|
+
import { formatDate, formatTerritorialScope, isPast } from '../core/format'
|
|
11
|
+
import { knownKey } from '../core/i18n'
|
|
12
|
+
import { failureText } from '../core/outcomes'
|
|
13
|
+
import { codePointLength, normalizeBody } from '../core/text'
|
|
14
|
+
import LinkedText from './LinkedText.vue'
|
|
15
|
+
import { uid, useText } from './context'
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{ statement: Statement; appeal: AppealDraft; withdrawing: boolean }>()
|
|
18
|
+
const emit = defineEmits<{ appeal: []; withdraw: [] }>()
|
|
19
|
+
const { t, lang } = useText()
|
|
20
|
+
const id = uid('bcm-statement')
|
|
21
|
+
|
|
22
|
+
const s = computed(() => props.statement)
|
|
23
|
+
const restriction = computed(() => knownKey(`restriction.${s.value.restriction}`))
|
|
24
|
+
const basis = computed(() => knownKey(`basis.${s.value.basis}`))
|
|
25
|
+
const category = computed(() => knownKey(`category.${s.value.category}`))
|
|
26
|
+
/** `all` in words, a list of regions by their names; a value of a newer kb-api as it came. */
|
|
27
|
+
const scope = computed(() => formatTerritorialScope(lang.value, s.value.territorialScope))
|
|
28
|
+
const appealStatus = computed(() => (s.value.appeal ? knownKey(`appealStatus.${s.value.appeal.status}`) : null))
|
|
29
|
+
const subjectKey = computed(() =>
|
|
30
|
+
s.value.subject.kind === 'message' ? 'statement.subjectMessage' : s.value.subject.kind === 'article' ? 'statement.subjectArticle' : 'statement.subjectProfile',
|
|
31
|
+
)
|
|
32
|
+
const subjectText = computed(() => {
|
|
33
|
+
const subject = s.value.subject
|
|
34
|
+
return subject.kind === 'message' && subject.message ? (subject.message.body ?? subject.message.displayBody) : null
|
|
35
|
+
})
|
|
36
|
+
const appealOpen = computed(() => s.value.appeal?.status === 'open')
|
|
37
|
+
const windowClosed = computed(() => isPast(s.value.appealWindowClosesAt))
|
|
38
|
+
const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
|
|
39
|
+
const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
|
|
40
|
+
const failure = computed(() => {
|
|
41
|
+
const f = props.appeal.failure
|
|
42
|
+
if (!f) return ''
|
|
43
|
+
const r = failureText(f, lang.value)
|
|
44
|
+
return t(r.key, r.params)
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<article class="bcm-section" :aria-labelledby="`${id}-title`">
|
|
50
|
+
<h3 :id="`${id}-title`">{{ t('statement.title') }}</h3>
|
|
51
|
+
<p class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, s.decidedAt) }) }}</p>
|
|
52
|
+
<p>{{ t('statement.provider', { entity: s.providerEntity }) }}</p>
|
|
53
|
+
|
|
54
|
+
<dl class="bcm-dl">
|
|
55
|
+
<template v-if="restriction">
|
|
56
|
+
<dt>{{ t('statement.restriction') }}</dt>
|
|
57
|
+
<dd>
|
|
58
|
+
{{ t(restriction) }}
|
|
59
|
+
<span v-if="s.restrictionUntil" class="bcm-muted">{{ t('statement.until', { date: formatDate(lang, s.restrictionUntil) }) }}</span>
|
|
60
|
+
</dd>
|
|
61
|
+
</template>
|
|
62
|
+
<template v-if="category">
|
|
63
|
+
<dt>{{ t('report.category') }}</dt>
|
|
64
|
+
<dd>{{ t(category) }}</dd>
|
|
65
|
+
</template>
|
|
66
|
+
<template v-if="scope">
|
|
67
|
+
<dt>{{ t('statement.scope') }}</dt>
|
|
68
|
+
<dd>{{ scope }}</dd>
|
|
69
|
+
</template>
|
|
70
|
+
<template v-if="basis">
|
|
71
|
+
<dt>{{ t('statement.basis') }}</dt>
|
|
72
|
+
<dd>{{ t(basis) }}</dd>
|
|
73
|
+
</template>
|
|
74
|
+
<dt>{{ t('statement.automation') }}</dt>
|
|
75
|
+
<dd>
|
|
76
|
+
<span v-if="s.automatedDetection">{{ t('statement.automatedDetection') }}</span>
|
|
77
|
+
<span>{{ s.automated ? t('statement.automatedDecision') : t('statement.humanDecision') }}</span>
|
|
78
|
+
</dd>
|
|
79
|
+
<template v-if="s.legalGround">
|
|
80
|
+
<dt>{{ t('statement.legalGround') }}</dt>
|
|
81
|
+
<dd>
|
|
82
|
+
<p>{{ s.legalGround }}</p>
|
|
83
|
+
<p v-if="s.legalExplanation" class="bcm-text">{{ s.legalExplanation }}</p>
|
|
84
|
+
</dd>
|
|
85
|
+
</template>
|
|
86
|
+
<template v-if="s.contractualGround">
|
|
87
|
+
<dt>{{ t('statement.contractualGround') }}</dt>
|
|
88
|
+
<dd>
|
|
89
|
+
<p>{{ s.contractualGround }}</p>
|
|
90
|
+
<p v-if="s.contractualExplanation" class="bcm-text">{{ s.contractualExplanation }}</p>
|
|
91
|
+
</dd>
|
|
92
|
+
</template>
|
|
93
|
+
<dt>{{ t('statement.subject') }}</dt>
|
|
94
|
+
<dd>
|
|
95
|
+
<p>{{ t(subjectKey) }}</p>
|
|
96
|
+
<p v-if="subjectText" class="bcm-note bcm-text"><LinkedText :text="subjectText" /></p>
|
|
97
|
+
</dd>
|
|
98
|
+
</dl>
|
|
99
|
+
|
|
100
|
+
<h4>{{ t('statement.facts') }}</h4>
|
|
101
|
+
<pre class="bcm-facts">{{ s.facts }}</pre>
|
|
102
|
+
|
|
103
|
+
<h4>{{ t('statement.redress') }}</h4>
|
|
104
|
+
<p class="bcm-text">{{ s.redress }}</p>
|
|
105
|
+
|
|
106
|
+
<details>
|
|
107
|
+
<summary>{{ t('statement.full') }}</summary>
|
|
108
|
+
<pre class="bcm-facts">{{ s.statement }}</pre>
|
|
109
|
+
</details>
|
|
110
|
+
|
|
111
|
+
<section class="bcm-card" :aria-labelledby="`${id}-appeal`">
|
|
112
|
+
<h4 :id="`${id}-appeal`">{{ t('statement.appealTitle') }}</h4>
|
|
113
|
+
<template v-if="appealOpen">
|
|
114
|
+
<p>{{ t('statement.appealOpen') }}</p>
|
|
115
|
+
<p v-if="appealStatus" class="bcm-muted">{{ t(appealStatus) }}</p>
|
|
116
|
+
<div class="bcm-row">
|
|
117
|
+
<button type="button" class="bcm-btn" :disabled="withdrawing" @click="emit('withdraw')">{{ t('statement.appealWithdraw') }}</button>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
<template v-else-if="s.canAppeal">
|
|
121
|
+
<p>{{ t('statement.appealLede') }}</p>
|
|
122
|
+
<p class="bcm-muted">{{ t('statement.appealUntil', { date: formatDate(lang, s.appealWindowClosesAt) }) }}</p>
|
|
123
|
+
<form class="bcm-form" novalidate @submit.prevent="canSend && emit('appeal')">
|
|
124
|
+
<div class="bcm-field">
|
|
125
|
+
<label :for="`${id}-body`">{{ t('statement.appealBody') }}</label>
|
|
126
|
+
<textarea :id="`${id}-body`" v-model="appeal.body" class="bcm-input bcm-textarea" rows="5" :aria-describedby="`${id}-counter`" :aria-invalid="length > COMMUNITY_APPEAL_MAX" />
|
|
127
|
+
<p :id="`${id}-counter`" class="bcm-counter">{{ t('statement.appealCounter', { length, max: COMMUNITY_APPEAL_MAX }) }}</p>
|
|
128
|
+
</div>
|
|
129
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
130
|
+
<div class="bcm-row">
|
|
131
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ appeal.sending ? t('statement.appealSending') : t('statement.appealSubmit') }}</button>
|
|
132
|
+
</div>
|
|
133
|
+
</form>
|
|
134
|
+
</template>
|
|
135
|
+
<template v-else>
|
|
136
|
+
<p v-if="appealStatus" class="bcm-muted">{{ t(appealStatus) }}</p>
|
|
137
|
+
<p>{{ windowClosed ? t('statement.appealClosed') : t('statement.appealUnavailable') }}</p>
|
|
138
|
+
</template>
|
|
139
|
+
</section>
|
|
140
|
+
</article>
|
|
141
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-welcome>`: the welcome page after joining (m2-contracts.md §3.10; the storefront's `/komunita/vitejte`) —
|
|
4
|
+
* the tenant's rules in summary (as the onboarding shows them, with the link to the rules page) and three links: the
|
|
5
|
+
* discussions, the settings, the member's own profile. A visitor gets the sign-in link in the profile's place.
|
|
6
|
+
*/
|
|
7
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
8
|
+
import { profilePath, signInLink, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
9
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
10
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
11
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
12
|
+
import { createWelcome, type WelcomeController } from '../core/profile'
|
|
13
|
+
import { provideText, uid } from './context'
|
|
14
|
+
|
|
15
|
+
defineOptions({ inheritAttrs: false })
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
apiBase?: string
|
|
19
|
+
siteBase?: string
|
|
20
|
+
lang?: string
|
|
21
|
+
/** JSON (or an object): `rulesPath`, `discussionsPath`, `settingsPath`, `profilePath`. */
|
|
22
|
+
routes?: string | Record<string, unknown>
|
|
23
|
+
signInHref?: string
|
|
24
|
+
transport?: CommunityTransport
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
28
|
+
const { t } = provideText(lang)
|
|
29
|
+
const id = uid('bcm-welcome')
|
|
30
|
+
|
|
31
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
32
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
33
|
+
const ctl: WelcomeController | null = client ? createWelcome({ client, lang: lang.value }) : null
|
|
34
|
+
const state = ctl?.state ?? null
|
|
35
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
|
|
36
|
+
const ownProfile = computed(() => (state?.handle ? profilePath(routes.value.profilePath, state.handle) : null))
|
|
37
|
+
|
|
38
|
+
onMounted(() => void ctl?.load())
|
|
39
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
40
|
+
|
|
41
|
+
function signInHref(): string | null {
|
|
42
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<section v-if="ctl && state" class="bcm bcm-welcome" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
48
|
+
<h2 :id="`${id}-title`">{{ t('welcome.title') }}</h2>
|
|
49
|
+
<p>{{ t('welcome.lede') }}</p>
|
|
50
|
+
|
|
51
|
+
<p v-if="state.phase !== 'ready'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
52
|
+
<template v-else>
|
|
53
|
+
<section class="bcm-section" :aria-labelledby="`${id}-rules`">
|
|
54
|
+
<h3 :id="`${id}-rules`">{{ t('welcome.rules') }}</h3>
|
|
55
|
+
<ol v-if="state.rules.length" class="bcm-rules">
|
|
56
|
+
<li v-for="rule in state.rules" :key="rule.id">
|
|
57
|
+
<strong>{{ rule.title }}</strong>
|
|
58
|
+
<span v-if="rule.summary" class="bcm-hint">{{ rule.summary }}</span>
|
|
59
|
+
</li>
|
|
60
|
+
</ol>
|
|
61
|
+
<p><a :href="routes.rulesPath">{{ t('onboarding.rulesLink') }}</a></p>
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<ul class="bcm-links">
|
|
65
|
+
<li><a class="bcm-btn" :href="routes.discussionsPath">{{ t('welcome.discussions') }}</a></li>
|
|
66
|
+
<li><a class="bcm-btn" :href="routes.settingsPath">{{ t('welcome.settings') }}</a></li>
|
|
67
|
+
<li v-if="ownProfile"><a class="bcm-btn" :href="ownProfile">{{ t('welcome.profile') }}</a></li>
|
|
68
|
+
<li v-else-if="state.visitor && signInHref()"><a class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a></li>
|
|
69
|
+
</ul>
|
|
70
|
+
</template>
|
|
71
|
+
</section>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the components of one element share: the language and its texts (`t` reads the catalogue through
|
|
3
|
+
* `communityMessage` and nothing else), ids unique to the element, and the dialog's focus handling.
|
|
4
|
+
*/
|
|
5
|
+
import { inject, nextTick, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
|
|
6
|
+
import { communityMessage } from '@blogmcp/sdk/community'
|
|
7
|
+
import type { Lang, TextRef, Translate } from '../core/i18n'
|
|
8
|
+
|
|
9
|
+
export interface TextContext {
|
|
10
|
+
lang: Ref<Lang>
|
|
11
|
+
t: Translate
|
|
12
|
+
/** A TextRef (a controller's outcome) as text. */
|
|
13
|
+
text(ref: TextRef | null | undefined): string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TEXT: InjectionKey<TextContext> = Symbol('bcm-text')
|
|
17
|
+
|
|
18
|
+
export function provideText(lang: Ref<Lang>): TextContext {
|
|
19
|
+
// Reads lang.value on every call, so a view that renders a text re-renders when the language changes.
|
|
20
|
+
const t: Translate = (key, params) => communityMessage(lang.value, key, params)
|
|
21
|
+
const ctx: TextContext = { lang, t, text: (ref) => (ref ? t(ref.key, ref.params) : '') }
|
|
22
|
+
provide(TEXT, ctx)
|
|
23
|
+
return ctx
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useText(): TextContext {
|
|
27
|
+
const ctx = inject(TEXT, null)
|
|
28
|
+
if (!ctx) throw new Error('[blogmcp-community] a component rendered outside its element')
|
|
29
|
+
return ctx
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let counter = 0
|
|
33
|
+
/** An id unique on the page (ids live in each element's shadow root, but the labels reference them). */
|
|
34
|
+
export function uid(prefix: string): string {
|
|
35
|
+
counter += 1
|
|
36
|
+
return `${prefix}-${counter}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A native `<dialog>` opened modally while the component lives: the top layer, Esc, the focus kept
|
|
41
|
+
* inside — and returned to where it was (or to `fallback` when that is gone, e.g. a deleted message's menu).
|
|
42
|
+
*/
|
|
43
|
+
export function useModal(dialog: Ref<HTMLDialogElement | null>, onCancel: () => void, fallback?: () => HTMLElement | null): void {
|
|
44
|
+
let previous: HTMLElement | null = null
|
|
45
|
+
const onCancelEvent = (e: Event): void => {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
onCancel()
|
|
48
|
+
}
|
|
49
|
+
onMounted(() => {
|
|
50
|
+
const el = dialog.value
|
|
51
|
+
if (!el) return
|
|
52
|
+
const root = el.getRootNode() as Document | ShadowRoot
|
|
53
|
+
previous = (root.activeElement as HTMLElement | null) ?? null
|
|
54
|
+
el.addEventListener('cancel', onCancelEvent)
|
|
55
|
+
if (typeof el.showModal === 'function') {
|
|
56
|
+
try {
|
|
57
|
+
el.showModal()
|
|
58
|
+
} catch {
|
|
59
|
+
el.setAttribute('open', '')
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
el.setAttribute('open', '')
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
onBeforeUnmount(() => {
|
|
66
|
+
const el = dialog.value
|
|
67
|
+
el?.removeEventListener('cancel', onCancelEvent)
|
|
68
|
+
if (el?.open && typeof el.close === 'function') el.close()
|
|
69
|
+
const back = previous
|
|
70
|
+
void nextTick(() => {
|
|
71
|
+
const target = back && back.isConnected ? back : (fallback?.() ?? null)
|
|
72
|
+
target?.focus()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** What the discussion's components share: the controller, the host's routes, what the reader opened. */
|
|
2
|
+
import { inject, provide, type ComputedRef, type InjectionKey } from 'vue'
|
|
3
|
+
import type { DiscussionController } from '../core/discussion'
|
|
4
|
+
import type { routesWithDefaults } from '../core/attrs'
|
|
5
|
+
|
|
6
|
+
export interface DiscussionContext {
|
|
7
|
+
ctl: DiscussionController
|
|
8
|
+
routes: ComputedRef<ReturnType<typeof routesWithDefaults>>
|
|
9
|
+
/** Messages of a blocked author the reader opened anyway. */
|
|
10
|
+
opened: Set<number>
|
|
11
|
+
/** Translated messages the reader reads in the original. */
|
|
12
|
+
originals: Set<number>
|
|
13
|
+
/** Top-level messages whose every reply the reader asked to see. */
|
|
14
|
+
allReplies: Set<number>
|
|
15
|
+
/** Where the focus goes when the element it came from is gone (a deleted message). */
|
|
16
|
+
focusHeading(): HTMLElement | null
|
|
17
|
+
deleteMessage(seq: number): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KEY: InjectionKey<DiscussionContext> = Symbol('bcm-discussion')
|
|
21
|
+
|
|
22
|
+
export function provideDiscussion(ctx: DiscussionContext): void {
|
|
23
|
+
provide(KEY, ctx)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useDiscussion(): DiscussionContext {
|
|
27
|
+
const ctx = inject(KEY, null)
|
|
28
|
+
if (!ctx) throw new Error('[blogmcp-community] a discussion component rendered outside <blogmcp-discussion>')
|
|
29
|
+
return ctx
|
|
30
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/* The community elements' one stylesheet. It lives in each element's shadow root: nothing here reaches the
|
|
2
|
+
site, nothing of the site's reaches here but what :host lets through. A site themes the elements with the
|
|
3
|
+
--bcm-* custom properties (set them on the element: `blogmcp-discussion { --bcm-accent: … }`). */
|
|
4
|
+
|
|
5
|
+
:host {
|
|
6
|
+
all: initial;
|
|
7
|
+
display: block;
|
|
8
|
+
--bcm-bg: #ffffff;
|
|
9
|
+
--bcm-fg: #1d1f23;
|
|
10
|
+
--bcm-muted: #5f6672;
|
|
11
|
+
--bcm-line: #dfe2e7;
|
|
12
|
+
--bcm-soft: #f4f5f7;
|
|
13
|
+
--bcm-accent: #2457d6;
|
|
14
|
+
--bcm-accent-fg: #ffffff;
|
|
15
|
+
--bcm-danger: #b42318;
|
|
16
|
+
--bcm-ok: #1f7a3f;
|
|
17
|
+
--bcm-warn-bg: #fff6d6;
|
|
18
|
+
--bcm-shadow: 0 8px 28px rgba(16, 24, 40, 0.18);
|
|
19
|
+
--bcm-z: 20;
|
|
20
|
+
color-scheme: light dark;
|
|
21
|
+
font: var(--bcm-font-size, 16px) / 1.5 var(--bcm-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
|
|
22
|
+
color: var(--bcm-fg);
|
|
23
|
+
}
|
|
24
|
+
:host([hidden]) { display: none; }
|
|
25
|
+
|
|
26
|
+
@media (prefers-color-scheme: dark) {
|
|
27
|
+
:host {
|
|
28
|
+
--bcm-bg: #1c1f24;
|
|
29
|
+
--bcm-fg: #eceef1;
|
|
30
|
+
--bcm-muted: #a4abb6;
|
|
31
|
+
--bcm-line: #343943;
|
|
32
|
+
--bcm-soft: #262a31;
|
|
33
|
+
--bcm-accent: #6d93ff;
|
|
34
|
+
--bcm-accent-fg: #0d1117;
|
|
35
|
+
--bcm-danger: #ff8a7a;
|
|
36
|
+
--bcm-ok: #6fd08e;
|
|
37
|
+
--bcm-warn-bg: #3a3420;
|
|
38
|
+
--bcm-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
* { box-sizing: border-box; }
|
|
43
|
+
h2, h3, h4 { margin: 0; font-weight: 650; line-height: 1.3; }
|
|
44
|
+
h2 { font-size: 1.25em; }
|
|
45
|
+
h3 { font-size: 1.05em; }
|
|
46
|
+
h4 { font-size: 1em; }
|
|
47
|
+
p { margin: 0; }
|
|
48
|
+
ul, ol { margin: 0; padding: 0; }
|
|
49
|
+
a { color: var(--bcm-accent); }
|
|
50
|
+
|
|
51
|
+
.bcm {
|
|
52
|
+
background: var(--bcm-bg);
|
|
53
|
+
color: var(--bcm-fg);
|
|
54
|
+
border-radius: 14px;
|
|
55
|
+
padding: 16px;
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: column;
|
|
58
|
+
gap: 16px;
|
|
59
|
+
max-width: 100%;
|
|
60
|
+
overflow-wrap: anywhere;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* visually hidden, read by screen readers */
|
|
64
|
+
.bcm-sr {
|
|
65
|
+
position: absolute !important;
|
|
66
|
+
width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
67
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ── controls ── */
|
|
71
|
+
.bcm-btn {
|
|
72
|
+
font: inherit;
|
|
73
|
+
display: inline-flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
gap: 6px;
|
|
77
|
+
min-height: 36px;
|
|
78
|
+
padding: 6px 14px;
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
border: 1px solid var(--bcm-line);
|
|
81
|
+
background: var(--bcm-bg);
|
|
82
|
+
color: var(--bcm-fg);
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
text-decoration: none;
|
|
85
|
+
}
|
|
86
|
+
.bcm-btn:hover:not(:disabled) { background: var(--bcm-soft); }
|
|
87
|
+
.bcm-btn:disabled { opacity: 0.55; cursor: default; }
|
|
88
|
+
.bcm-primary { background: var(--bcm-accent); border-color: var(--bcm-accent); color: var(--bcm-accent-fg); }
|
|
89
|
+
.bcm-primary:hover:not(:disabled) { background: var(--bcm-accent); filter: brightness(1.08); }
|
|
90
|
+
.bcm-danger { color: var(--bcm-danger); }
|
|
91
|
+
.bcm-primary.bcm-danger { background: var(--bcm-danger); border-color: var(--bcm-danger); color: var(--bcm-bg); }
|
|
92
|
+
.bcm-small { min-height: 30px; padding: 2px 10px; font-size: 0.875em; }
|
|
93
|
+
.bcm-link { font: inherit; border: 0; background: none; padding: 0; color: var(--bcm-accent); cursor: pointer; text-align: left; text-decoration: underline; }
|
|
94
|
+
.bcm-quiet { font: inherit; border: 0; background: none; padding: 4px 6px; color: var(--bcm-muted); cursor: pointer; border-radius: 6px; min-height: 30px; }
|
|
95
|
+
.bcm-quiet:hover:not(:disabled) { background: var(--bcm-soft); color: var(--bcm-fg); }
|
|
96
|
+
.bcm-btn:focus-visible, .bcm-link:focus-visible, .bcm-quiet:focus-visible, .bcm-input:focus-visible, .bcm-react:focus-visible, a:focus-visible,
|
|
97
|
+
.bcm-choice input:focus-visible + span {
|
|
98
|
+
outline: 2px solid var(--bcm-accent);
|
|
99
|
+
outline-offset: 2px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.bcm-input {
|
|
103
|
+
font: inherit;
|
|
104
|
+
width: 100%;
|
|
105
|
+
padding: 8px 10px;
|
|
106
|
+
border-radius: 8px;
|
|
107
|
+
border: 1px solid var(--bcm-line);
|
|
108
|
+
background: var(--bcm-bg);
|
|
109
|
+
color: var(--bcm-fg);
|
|
110
|
+
}
|
|
111
|
+
.bcm-input[aria-invalid='true'] { border-color: var(--bcm-danger); border-width: 2px; }
|
|
112
|
+
.bcm-textarea { resize: vertical; min-height: 96px; }
|
|
113
|
+
.bcm-field { display: flex; flex-direction: column; gap: 4px; }
|
|
114
|
+
.bcm-field > label, .bcm-label { font-weight: 600; }
|
|
115
|
+
.bcm-check { display: flex; gap: 8px; align-items: flex-start; }
|
|
116
|
+
.bcm-check input { margin-top: 0.3em; width: 18px; height: 18px; flex: none; }
|
|
117
|
+
.bcm-choices { display: flex; flex-wrap: wrap; gap: 8px; border: 0; margin: 0; padding: 0; min-width: 0; }
|
|
118
|
+
.bcm-choices legend, .bcm-fieldset legend { font-weight: 600; padding: 0; margin-bottom: 6px; }
|
|
119
|
+
.bcm-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
120
|
+
.bcm-choice { position: relative; display: inline-flex; }
|
|
121
|
+
.bcm-choice input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
|
122
|
+
.bcm-choice span {
|
|
123
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
|
124
|
+
min-height: 40px; min-width: 40px; padding: 6px 12px;
|
|
125
|
+
border: 1px solid var(--bcm-line); border-radius: 999px; cursor: pointer; background: var(--bcm-bg);
|
|
126
|
+
}
|
|
127
|
+
/* The chosen one: a thicker border and a check, not colour alone. */
|
|
128
|
+
.bcm-choice input:checked + span { border: 2px solid var(--bcm-accent); font-weight: 650; }
|
|
129
|
+
.bcm-choice input:checked + span::before { content: '✓'; }
|
|
130
|
+
|
|
131
|
+
/* ── text ── */
|
|
132
|
+
.bcm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
133
|
+
.bcm-spacer { flex: 1; }
|
|
134
|
+
.bcm-full { flex-basis: 100%; }
|
|
135
|
+
.bcm-muted, .bcm-hint { color: var(--bcm-muted); font-size: 0.875em; }
|
|
136
|
+
.bcm-error { color: var(--bcm-danger); }
|
|
137
|
+
.bcm-ok { color: var(--bcm-ok); }
|
|
138
|
+
.bcm-note { background: var(--bcm-soft); border-radius: 8px; padding: 8px 12px; font-size: 0.9375em; }
|
|
139
|
+
.bcm-warning { background: var(--bcm-warn-bg); border-radius: 8px; padding: 8px 12px; font-size: 0.9375em; }
|
|
140
|
+
.bcm-banner {
|
|
141
|
+
background: var(--bcm-warn-bg); border-radius: 10px; padding: 10px 12px;
|
|
142
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
143
|
+
}
|
|
144
|
+
.bcm-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
145
|
+
.bcm-facts {
|
|
146
|
+
font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0;
|
|
147
|
+
background: var(--bcm-soft); border-radius: 8px; padding: 10px 12px;
|
|
148
|
+
}
|
|
149
|
+
.bcm-badge {
|
|
150
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
151
|
+
border: 1px solid currentColor; border-radius: 999px; padding: 0 8px;
|
|
152
|
+
font-size: 0.8125em; line-height: 1.6;
|
|
153
|
+
}
|
|
154
|
+
.bcm-badge-team { color: var(--bcm-accent); font-weight: 650; }
|
|
155
|
+
|
|
156
|
+
/* ── the discussion ── */
|
|
157
|
+
.bcm-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
158
|
+
.bcm-count { color: var(--bcm-muted); }
|
|
159
|
+
.bcm-thread, .bcm-replies { list-style: none; display: flex; flex-direction: column; gap: 12px; }
|
|
160
|
+
.bcm-replies { margin-top: 12px; padding-left: 20px; border-left: 2px solid var(--bcm-line); }
|
|
161
|
+
.bcm-msg { display: flex; gap: 10px; align-items: flex-start; }
|
|
162
|
+
.bcm-msg-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
163
|
+
.bcm-msg-pending { opacity: 0.65; }
|
|
164
|
+
.bcm-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; font-size: 0.875em; }
|
|
165
|
+
.bcm-author { font-weight: 650; }
|
|
166
|
+
.bcm-handle, .bcm-time { color: var(--bcm-muted); }
|
|
167
|
+
.bcm-handle::before { content: '@'; }
|
|
168
|
+
.bcm-placeholder { color: var(--bcm-muted); font-style: italic; }
|
|
169
|
+
.bcm-own-state { border-left: 3px solid var(--bcm-line); padding-left: 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
170
|
+
.bcm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 2px 6px; }
|
|
171
|
+
.bcm-avatar {
|
|
172
|
+
flex: none; width: 36px; height: 36px; border-radius: 999px;
|
|
173
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
174
|
+
background: var(--bcm-soft); font-size: 20px; line-height: 1; user-select: none;
|
|
175
|
+
}
|
|
176
|
+
.bcm-avatar-initial { color: #fff; font-size: 16px; font-weight: 650; }
|
|
177
|
+
.bcm-reactions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
178
|
+
.bcm-react {
|
|
179
|
+
font: inherit; display: inline-flex; align-items: center; gap: 4px;
|
|
180
|
+
min-height: 32px; padding: 2px 10px; border-radius: 999px;
|
|
181
|
+
border: 1px solid var(--bcm-line); background: var(--bcm-bg); color: var(--bcm-fg); cursor: pointer;
|
|
182
|
+
}
|
|
183
|
+
button.bcm-react:hover:not(:disabled) { background: var(--bcm-soft); }
|
|
184
|
+
/* The viewer's own: a thicker border and a bold count, not colour alone. */
|
|
185
|
+
.bcm-react[aria-pressed='true'] { border: 2px solid var(--bcm-accent); font-weight: 700; }
|
|
186
|
+
span.bcm-react { cursor: default; }
|
|
187
|
+
.bcm-menu { position: relative; display: inline-flex; }
|
|
188
|
+
.bcm-menu-list {
|
|
189
|
+
position: absolute; right: 0; top: calc(100% + 4px); z-index: var(--bcm-z);
|
|
190
|
+
min-width: 200px; display: flex; flex-direction: column; padding: 4px;
|
|
191
|
+
background: var(--bcm-bg); border: 1px solid var(--bcm-line); border-radius: 10px; box-shadow: var(--bcm-shadow);
|
|
192
|
+
}
|
|
193
|
+
.bcm-menu-list button, .bcm-menu-list a {
|
|
194
|
+
font: inherit; text-align: left; border: 0; background: none; color: var(--bcm-fg);
|
|
195
|
+
padding: 8px 10px; border-radius: 6px; cursor: pointer; text-decoration: none; min-height: 36px;
|
|
196
|
+
}
|
|
197
|
+
.bcm-menu-list button:hover, .bcm-menu-list a:hover { background: var(--bcm-soft); }
|
|
198
|
+
|
|
199
|
+
/* ── the composer ── */
|
|
200
|
+
.bcm-composer { display: flex; flex-direction: column; gap: 8px; }
|
|
201
|
+
.bcm-composer-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
202
|
+
.bcm-counter { color: var(--bcm-muted); font-size: 0.8125em; font-variant-numeric: tabular-nums; }
|
|
203
|
+
.bcm-counter-over { color: var(--bcm-danger); font-weight: 650; }
|
|
204
|
+
.bcm-confirm { border: 2px solid var(--bcm-accent); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
205
|
+
|
|
206
|
+
/* ── onboarding, forms, the panel ── */
|
|
207
|
+
.bcm-card { border: 1px solid var(--bcm-line); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
208
|
+
.bcm-steps { color: var(--bcm-muted); font-size: 0.875em; }
|
|
209
|
+
.bcm-rules { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
|
210
|
+
.bcm-rules li { display: flex; flex-direction: column; gap: 2px; }
|
|
211
|
+
.bcm-consent { max-height: 260px; overflow: auto; border: 1px solid var(--bcm-line); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
212
|
+
.bcm-form { display: flex; flex-direction: column; gap: 14px; }
|
|
213
|
+
.bcm-section { display: flex; flex-direction: column; gap: 10px; }
|
|
214
|
+
.bcm-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
|
215
|
+
.bcm-list > li { border: 1px solid var(--bcm-line); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
|
|
216
|
+
.bcm-unread { border-left: 4px solid var(--bcm-accent) !important; }
|
|
217
|
+
.bcm-dl { display: grid; grid-template-columns: minmax(120px, max-content) 1fr; gap: 6px 14px; margin: 0; }
|
|
218
|
+
.bcm-dl dt { font-weight: 600; }
|
|
219
|
+
.bcm-dl dd { margin: 0; }
|
|
220
|
+
.bcm-receipt { display: flex; flex-direction: column; gap: 8px; }
|
|
221
|
+
.bcm-turnstile { min-height: 0; }
|
|
222
|
+
|
|
223
|
+
/* ── M2: mentions, the badge, the centre, settings, profiles, the welcome page ── */
|
|
224
|
+
.bcm-mention { color: var(--bcm-accent); font-weight: 600; text-decoration: none; }
|
|
225
|
+
.bcm-mention:hover { text-decoration: underline; }
|
|
226
|
+
:host(blogmcp-badge) { display: inline-block; }
|
|
227
|
+
.bcm-badge-link {
|
|
228
|
+
display: inline-flex; align-items: center; justify-content: center; min-width: 1.5em; height: 1.5em; padding: 0 6px;
|
|
229
|
+
border-radius: 999px; background: var(--bcm-accent); color: var(--bcm-accent-fg); font-size: 0.8125em; font-weight: 700;
|
|
230
|
+
line-height: 1; text-decoration: none; font-variant-numeric: tabular-nums;
|
|
231
|
+
}
|
|
232
|
+
.bcm-switches { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
|
233
|
+
.bcm-switches li { display: flex; flex-direction: column; gap: 2px; }
|
|
234
|
+
.bcm-profile-head { display: flex; align-items: center; gap: 12px; }
|
|
235
|
+
.bcm-profile-head .bcm-avatar { width: 48px; height: 48px; font-size: 26px; }
|
|
236
|
+
.bcm-links { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
|
237
|
+
.bcm-url { overflow-wrap: anywhere; font-family: ui-monospace, monospace; font-size: 0.875em; }
|
|
238
|
+
|
|
239
|
+
/* ── dialogs (native <dialog>: the top layer, Esc, focus kept inside) ── */
|
|
240
|
+
.bcm-dialog {
|
|
241
|
+
border: 1px solid var(--bcm-line); border-radius: 14px; padding: 0;
|
|
242
|
+
width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
|
|
243
|
+
background: var(--bcm-bg); color: var(--bcm-fg); box-shadow: var(--bcm-shadow);
|
|
244
|
+
}
|
|
245
|
+
.bcm-dialog::backdrop { background: rgba(10, 12, 16, 0.45); }
|
|
246
|
+
.bcm-dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
247
|
+
|
|
248
|
+
@media (max-width: 480px) {
|
|
249
|
+
.bcm { padding: 12px; border-radius: 0; }
|
|
250
|
+
.bcm-replies { padding-left: 12px; }
|
|
251
|
+
.bcm-avatar { width: 30px; height: 30px; font-size: 17px; }
|
|
252
|
+
.bcm-dl { grid-template-columns: 1fr; }
|
|
253
|
+
.bcm-dialog { width: 100vw; max-width: 100vw; margin: auto 0 0; border-radius: 16px 16px 0 0; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
257
|
+
.bcm-dialog[open] { animation: bcm-in 140ms ease-out; }
|
|
258
|
+
@keyframes bcm-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
259
|
+
}
|
package/src/ui/types.ts
ADDED
package/src/version.ts
ADDED