@blogmcp/community 0.0.0-stage → 0.1.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 +146 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-DzjwnHyx.js +4597 -0
- package/dist/core/attrs.d.ts +73 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/discussion.d.ts +144 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +17 -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 +52 -0
- package/dist/core/outcomes.d.ts +28 -0
- package/dist/core/panel.d.ts +80 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/text.d.ts +22 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +26 -0
- package/dist/global.d.ts +8 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +22 -0
- package/dist/mount.d.ts +65 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +35 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +33 -0
- package/dist/ui/LinkedText.vue.d.ts +5 -0
- package/dist/ui/MessageComposer.vue.d.ts +24 -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/OnboardingSteps.vue.d.ts +11 -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/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 +89 -0
- package/dist/vue.js +97 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +147 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/client.ts +54 -0
- package/src/core/discussion.ts +691 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +90 -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 +345 -0
- package/src/core/outcomes.ts +74 -0
- package/src/core/panel.ts +411 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/text.ts +142 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +109 -0
- package/src/global.ts +12 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +127 -0
- package/src/react.ts +53 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +294 -0
- package/src/ui/LinkedText.vue +18 -0
- package/src/ui/MessageComposer.vue +130 -0
- package/src/ui/MessageItem.vue +205 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +369 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/OnboardingSteps.vue +163 -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/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +243 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +116 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-notice-form>`: the DSA notice form for anyone (m1-contracts.md §7.7, §10.4; community.md §5).
|
|
4
|
+
* The target comes from `target` (JSON), else `article-id`, else the discussion's report link (`#c=…&s=…`).
|
|
5
|
+
* Without one — the notice page opened directly, or a fragment that names nothing — the element says where a report
|
|
6
|
+
* starts (at the content itself) and links to the rules, instead of a form with nothing to report.
|
|
7
|
+
* The Turnstile widget renders into this element's light DOM (projected through the `turnstile` slot), its
|
|
8
|
+
* script loaded by this form only.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useHost, watch } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
CASE_CATEGORIES,
|
|
13
|
+
COMMUNITY_NOTICE_DESCRIPTION_MAX,
|
|
14
|
+
COMMUNITY_NOTIFIER_NAME_MAX,
|
|
15
|
+
type CaseCategory,
|
|
16
|
+
type CommunityMessageKey,
|
|
17
|
+
type CommunityTransport,
|
|
18
|
+
type NoticeTarget,
|
|
19
|
+
} from '@blogmcp/sdk/community'
|
|
20
|
+
import { parseRoutes, parseTarget, routesWithDefaults, targetFromHash, targetKind } from '../core/attrs'
|
|
21
|
+
import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
|
|
22
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
23
|
+
import { createNoticeForm, type NoticeFormController } from '../core/notice'
|
|
24
|
+
import { failureText } from '../core/outcomes'
|
|
25
|
+
import { renderTurnstile, type TurnstileWidget } from '../core/turnstile'
|
|
26
|
+
import { provideText, uid } from './context'
|
|
27
|
+
|
|
28
|
+
defineOptions({ inheritAttrs: false })
|
|
29
|
+
|
|
30
|
+
const props = defineProps<{
|
|
31
|
+
apiBase?: string
|
|
32
|
+
/** The path the site is served under (`/blog`): its proxy and its pages, as the defaults. */
|
|
33
|
+
siteBase?: string
|
|
34
|
+
lang?: string
|
|
35
|
+
turnstileSiteKey?: string
|
|
36
|
+
/** JSON (or an object): `{ conversationId, seq }`, `{ memberId }`, `{ handle }` or `{ articleId }`. */
|
|
37
|
+
target?: string | Record<string, unknown>
|
|
38
|
+
articleId?: string
|
|
39
|
+
/** JSON (or an object): `noticesPath` (the status page), `rulesPath`. */
|
|
40
|
+
routes?: string | Record<string, unknown>
|
|
41
|
+
transport?: CommunityTransport
|
|
42
|
+
}>()
|
|
43
|
+
|
|
44
|
+
const host = useHost()
|
|
45
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
46
|
+
const { t } = provideText(lang)
|
|
47
|
+
const id = uid('bcm-notice')
|
|
48
|
+
const heading = ref<HTMLElement | null>(null)
|
|
49
|
+
const done = ref<HTMLElement | null>(null)
|
|
50
|
+
|
|
51
|
+
function resolveTarget(): NoticeTarget | null {
|
|
52
|
+
const fromAttr = parseTarget(props.target)
|
|
53
|
+
if (fromAttr) return fromAttr
|
|
54
|
+
if (props.articleId) return parseTarget({ articleId: props.articleId })
|
|
55
|
+
return typeof location === 'undefined' ? null : targetFromHash(location.hash)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
59
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
|
|
60
|
+
const ctl = shallowRef<NoticeFormController | null>(null)
|
|
61
|
+
|
|
62
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
63
|
+
const target = resolveTarget()
|
|
64
|
+
/** Nothing to report (and a usable base): the explanation instead of the form. */
|
|
65
|
+
const untargeted = !!client && !target
|
|
66
|
+
if (untargeted) {
|
|
67
|
+
warnOnce(
|
|
68
|
+
'notice-target',
|
|
69
|
+
'[blogmcp-community] <blogmcp-notice-form> has no target (a target, an article-id or the report link’s #c=…&s= fragment): it shows where a report starts instead of the form',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
if (client && target) {
|
|
73
|
+
ctl.value = createNoticeForm({
|
|
74
|
+
client,
|
|
75
|
+
lang: lang.value,
|
|
76
|
+
target,
|
|
77
|
+
captcha: !!props.turnstileSiteKey,
|
|
78
|
+
noticesPath: routes.value.noticesPath,
|
|
79
|
+
onSubmitted: (answer) => host?.dispatchEvent(new CustomEvent('bcm-notice-submitted', { detail: answer, bubbles: true, composed: true })),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const state = computed(() => ctl.value?.state ?? null)
|
|
84
|
+
const kind = computed(() => (state.value?.target ? targetKind(state.value.target) : null))
|
|
85
|
+
const targetText = computed(() => (kind.value === 'message' ? t('notice.targetMessage') : kind.value === 'article' ? t('notice.targetArticle') : t('notice.targetProfile')))
|
|
86
|
+
const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
|
|
87
|
+
const failure = computed(() => {
|
|
88
|
+
const f = state.value?.failure
|
|
89
|
+
if (!f) return ''
|
|
90
|
+
const r = failureText(f, lang.value)
|
|
91
|
+
return t(r.key, r.params)
|
|
92
|
+
})
|
|
93
|
+
const absoluteStatusLink = computed(() => {
|
|
94
|
+
const link = state.value?.statusLink
|
|
95
|
+
return link && typeof location !== 'undefined' ? `${location.origin}${link}` : (link ?? '')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ── Turnstile, in the light DOM ──
|
|
99
|
+
let widget: TurnstileWidget | null = null
|
|
100
|
+
let slotted: HTMLElement | null = null
|
|
101
|
+
let gone = false
|
|
102
|
+
|
|
103
|
+
onMounted(async () => {
|
|
104
|
+
const c = ctl.value
|
|
105
|
+
if (!c || !props.turnstileSiteKey || !host) return
|
|
106
|
+
slotted = document.createElement('div')
|
|
107
|
+
slotted.slot = 'turnstile'
|
|
108
|
+
host.appendChild(slotted)
|
|
109
|
+
try {
|
|
110
|
+
const nonce = (host as HTMLElement & { nonce?: string }).nonce || host.getAttribute('nonce') || undefined
|
|
111
|
+
const w = await renderTurnstile(slotted, { siteKey: props.turnstileSiteKey, language: lang.value, onToken: (token) => c.setToken(token), nonce })
|
|
112
|
+
if (gone) {
|
|
113
|
+
w.remove()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
widget = w
|
|
117
|
+
c.setCaptcha('ready')
|
|
118
|
+
} catch {
|
|
119
|
+
c.setCaptcha('unavailable')
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
watch(
|
|
123
|
+
() => state.value?.resetSignal,
|
|
124
|
+
(n, before) => {
|
|
125
|
+
if (n !== undefined && before !== undefined && n !== before) widget?.reset()
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
watch(
|
|
129
|
+
() => state.value?.receipt,
|
|
130
|
+
(receipt) => {
|
|
131
|
+
if (!receipt) return
|
|
132
|
+
widget?.remove()
|
|
133
|
+
widget = null
|
|
134
|
+
void nextTick(() => done.value?.focus())
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
onBeforeUnmount(() => {
|
|
138
|
+
gone = true
|
|
139
|
+
widget?.remove()
|
|
140
|
+
widget = null
|
|
141
|
+
slotted?.remove()
|
|
142
|
+
slotted = null
|
|
143
|
+
ctl.value?.dispose()
|
|
144
|
+
})
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<section v-if="ctl && state" class="bcm bcm-notice" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
149
|
+
<h2 :id="`${id}-title`" ref="heading" tabindex="-1">{{ t('notice.title') }}</h2>
|
|
150
|
+
|
|
151
|
+
<div v-if="state.receipt" ref="done" class="bcm-receipt" role="status" tabindex="-1">
|
|
152
|
+
<p>{{ t('notice.done') }}</p>
|
|
153
|
+
<p>{{ t('notice.receiptLede') }}</p>
|
|
154
|
+
<p v-if="state.statusLink"><a :href="state.statusLink">{{ t('notice.statusLink') }}</a></p>
|
|
155
|
+
<input v-if="absoluteStatusLink" class="bcm-input" readonly :value="absoluteStatusLink" :aria-label="t('notice.statusLink')" @focus="($event.target as HTMLInputElement).select()" />
|
|
156
|
+
<p v-if="!state.receipt.contactKept" class="bcm-note">{{ t('notice.noContactKept') }}</p>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<form v-else class="bcm-form" novalidate @submit.prevent="ctl.submit()">
|
|
160
|
+
<p>{{ t('notice.lede') }}</p>
|
|
161
|
+
|
|
162
|
+
<div class="bcm-field">
|
|
163
|
+
<span class="bcm-label">{{ t('notice.target') }}</span>
|
|
164
|
+
<span>{{ targetText }}</span>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<fieldset class="bcm-choices">
|
|
168
|
+
<legend>{{ t('notice.category') }}</legend>
|
|
169
|
+
<label v-for="c in CASE_CATEGORIES" :key="c" class="bcm-choice">
|
|
170
|
+
<input type="radio" :name="`${id}-category`" :value="c" :checked="state.category === c" @change="ctl.setCategory(c)" />
|
|
171
|
+
<span>{{ t(categoryKey(c)) }}</span>
|
|
172
|
+
</label>
|
|
173
|
+
</fieldset>
|
|
174
|
+
|
|
175
|
+
<div v-if="state.category === 'rules' && state.rules && state.rules.length" class="bcm-field">
|
|
176
|
+
<label :for="`${id}-rule`">{{ t('notice.rule') }}</label>
|
|
177
|
+
<select :id="`${id}-rule`" v-model="state.ruleId" class="bcm-input">
|
|
178
|
+
<option value="" />
|
|
179
|
+
<option v-for="rule in state.rules" :key="rule.id" :value="rule.id">{{ rule.title }}</option>
|
|
180
|
+
</select>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="bcm-field">
|
|
184
|
+
<label :for="`${id}-description`">{{ t('notice.description') }}</label>
|
|
185
|
+
<textarea
|
|
186
|
+
:id="`${id}-description`"
|
|
187
|
+
v-model="state.description"
|
|
188
|
+
class="bcm-input bcm-textarea"
|
|
189
|
+
rows="5"
|
|
190
|
+
:aria-describedby="`${id}-description-hint`"
|
|
191
|
+
:aria-invalid="ctl.descriptionLength() > COMMUNITY_NOTICE_DESCRIPTION_MAX"
|
|
192
|
+
/>
|
|
193
|
+
<p :id="`${id}-description-hint`" class="bcm-hint">
|
|
194
|
+
{{ t('notice.descriptionHint') }}
|
|
195
|
+
{{ t('notice.counter', { length: ctl.descriptionLength(), max: COMMUNITY_NOTICE_DESCRIPTION_MAX }) }}
|
|
196
|
+
</p>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<fieldset v-if="ctl.contactAllowed()" class="bcm-fieldset">
|
|
200
|
+
<legend>{{ t('notice.contactTitle') }}</legend>
|
|
201
|
+
<p class="bcm-hint">{{ t('notice.contactHint') }}</p>
|
|
202
|
+
<div class="bcm-field">
|
|
203
|
+
<label :for="`${id}-name`">{{ t('notice.name') }}</label>
|
|
204
|
+
<input :id="`${id}-name`" v-model="state.name" class="bcm-input" autocomplete="name" :maxlength="COMMUNITY_NOTIFIER_NAME_MAX" />
|
|
205
|
+
</div>
|
|
206
|
+
<div class="bcm-field">
|
|
207
|
+
<label :for="`${id}-email`">{{ t('notice.email') }}</label>
|
|
208
|
+
<input
|
|
209
|
+
:id="`${id}-email`"
|
|
210
|
+
v-model="state.email"
|
|
211
|
+
class="bcm-input"
|
|
212
|
+
type="email"
|
|
213
|
+
autocomplete="email"
|
|
214
|
+
:aria-invalid="!!state.failure?.fields?.includes('notifier.email')"
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</fieldset>
|
|
218
|
+
<p v-else class="bcm-note">{{ t('notice.childAtRisk') }}</p>
|
|
219
|
+
|
|
220
|
+
<div v-if="state.captcha !== 'off'" class="bcm-turnstile">
|
|
221
|
+
<slot name="turnstile" />
|
|
222
|
+
<p v-if="state.captcha === 'unavailable'" class="bcm-error" role="alert">{{ t('notice.captchaUnavailable') }}</p>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<label class="bcm-check">
|
|
226
|
+
<input v-model="state.goodFaith" type="checkbox" />
|
|
227
|
+
<span>{{ t('confirm.good_faith') }}</span>
|
|
228
|
+
</label>
|
|
229
|
+
|
|
230
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
231
|
+
|
|
232
|
+
<div class="bcm-row">
|
|
233
|
+
<span class="bcm-spacer" />
|
|
234
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!ctl.canSubmit()">{{ state.sending ? t('notice.sending') : t('notice.submit') }}</button>
|
|
235
|
+
</div>
|
|
236
|
+
</form>
|
|
237
|
+
</section>
|
|
238
|
+
|
|
239
|
+
<section v-else-if="untargeted" class="bcm bcm-notice" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
240
|
+
<h2 :id="`${id}-title`">{{ t('notice.title') }}</h2>
|
|
241
|
+
<p>{{ t('notice.noTarget') }}</p>
|
|
242
|
+
<p><a :href="routes.rulesPath">{{ t('onboarding.rulesTitle') }}</a></p>
|
|
243
|
+
</section>
|
|
244
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A notifier's view of their notice (DSA art. 16(5), 17; m1-contracts.md §7.7 `notices/status`): its status,
|
|
4
|
+
* the statement once decided — never the case's package —, and the appeal while its window is open. The
|
|
5
|
+
* receipt that opened it came in the address's fragment and is gone from the address bar already.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import { COMMUNITY_APPEAL_MAX, type NoticeStatus } from '@blogmcp/sdk/community'
|
|
9
|
+
import type { AppealDraft } from '../core/panel'
|
|
10
|
+
import { formatDate, 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 { uid, useText } from './context'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ notice: NoticeStatus; appeal: AppealDraft; withdrawing: boolean }>()
|
|
17
|
+
const emit = defineEmits<{ appeal: []; withdraw: [] }>()
|
|
18
|
+
const { t, lang } = useText()
|
|
19
|
+
const id = uid('bcm-notice-status')
|
|
20
|
+
|
|
21
|
+
const n = computed(() => props.notice)
|
|
22
|
+
const category = computed(() => knownKey(`category.${n.value.category}`))
|
|
23
|
+
const status = computed(() => knownKey(`caseStatus.${n.value.status}`))
|
|
24
|
+
const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
|
|
25
|
+
const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
|
|
26
|
+
const closed = computed(() => (n.value.appeal.closesAt ? isPast(n.value.appeal.closesAt) : false))
|
|
27
|
+
const failure = computed(() => {
|
|
28
|
+
const f = props.appeal.failure
|
|
29
|
+
if (!f) return ''
|
|
30
|
+
const r = failureText(f, lang.value)
|
|
31
|
+
return t(r.key, r.params)
|
|
32
|
+
})
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<section class="bcm-section" :aria-labelledby="`${id}-title`">
|
|
37
|
+
<h3 :id="`${id}-title`">{{ t('notice.statusTitle') }}</h3>
|
|
38
|
+
<dl class="bcm-dl">
|
|
39
|
+
<template v-if="category">
|
|
40
|
+
<dt>{{ t('notice.category') }}</dt>
|
|
41
|
+
<dd>{{ t(category) }}</dd>
|
|
42
|
+
</template>
|
|
43
|
+
<template v-if="status">
|
|
44
|
+
<dt>{{ t('notice.statusTitle') }}</dt>
|
|
45
|
+
<dd>{{ t(status) }}</dd>
|
|
46
|
+
</template>
|
|
47
|
+
</dl>
|
|
48
|
+
<p class="bcm-muted">{{ t('notice.submittedAt', { date: formatDate(lang, n.submittedAt) }) }}</p>
|
|
49
|
+
<p v-if="n.decidedAt" class="bcm-muted">{{ t('notice.decidedAt', { date: formatDate(lang, n.decidedAt) }) }}</p>
|
|
50
|
+
<template v-if="n.statement">
|
|
51
|
+
<h4>{{ t('notice.statement') }}</h4>
|
|
52
|
+
<pre class="bcm-facts">{{ n.statement }}</pre>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<section v-if="n.decidedAt" class="bcm-card" :aria-labelledby="`${id}-appeal`">
|
|
56
|
+
<h4 :id="`${id}-appeal`">{{ t('notice.appealTitle') }}</h4>
|
|
57
|
+
<template v-if="n.appeal.open">
|
|
58
|
+
<p>{{ t('notice.appealOpen') }}</p>
|
|
59
|
+
<div class="bcm-row">
|
|
60
|
+
<button type="button" class="bcm-btn" :disabled="withdrawing" @click="emit('withdraw')">{{ t('notice.appealWithdraw') }}</button>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
<template v-else-if="n.appeal.canAppeal">
|
|
64
|
+
<p>{{ t('notice.appealLede') }}</p>
|
|
65
|
+
<p v-if="n.appeal.closesAt" class="bcm-muted">{{ t('notice.appealUntil', { date: formatDate(lang, n.appeal.closesAt) }) }}</p>
|
|
66
|
+
<form class="bcm-form" novalidate @submit.prevent="canSend && emit('appeal')">
|
|
67
|
+
<div class="bcm-field">
|
|
68
|
+
<label :for="`${id}-body`">{{ t('notice.appealBody') }}</label>
|
|
69
|
+
<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" />
|
|
70
|
+
<p :id="`${id}-counter`" class="bcm-counter">{{ t('statement.appealCounter', { length, max: COMMUNITY_APPEAL_MAX }) }}</p>
|
|
71
|
+
</div>
|
|
72
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
73
|
+
<div class="bcm-row">
|
|
74
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ appeal.sending ? t('statement.appealSending') : t('notice.appealSubmit') }}</button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
</template>
|
|
78
|
+
<p v-else-if="closed">{{ t('notice.appealClosed') }}</p>
|
|
79
|
+
<p v-else>{{ t('statement.appealUnavailable') }}</p>
|
|
80
|
+
</section>
|
|
81
|
+
</section>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The inline onboarding (core/onboarding.ts): one step at a time, the focus moved to each step's heading,
|
|
4
|
+
* the rules as text with a link to the full page, the art. 9 text as text paragraphs.
|
|
5
|
+
*/
|
|
6
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
7
|
+
import { AGE_BANDS, COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_HANDLE_CHANGE_DAYS, type AgeBand, type CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
8
|
+
import { AVATAR_PRESETS } from '../core/avatars'
|
|
9
|
+
import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
|
|
10
|
+
import type { OnboardingModel } from '../core/onboarding'
|
|
11
|
+
import { failureText } from '../core/outcomes'
|
|
12
|
+
import { uid, useText } from './context'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{ model: OnboardingModel; rulesHref: string }>()
|
|
15
|
+
const emit = defineEmits<{ cancel: [] }>()
|
|
16
|
+
const { t, lang } = useText()
|
|
17
|
+
|
|
18
|
+
const s = props.model.state
|
|
19
|
+
const id = uid('bcm-onboarding')
|
|
20
|
+
const heading = ref<HTMLElement | null>(null)
|
|
21
|
+
const step = computed(() => props.model.step())
|
|
22
|
+
const fresh = computed(() => s.steps.includes('profile'))
|
|
23
|
+
|
|
24
|
+
const failure = computed(() => {
|
|
25
|
+
const f = s.failure
|
|
26
|
+
if (!f) return ''
|
|
27
|
+
const ref = failureText(f, lang.value)
|
|
28
|
+
return t(ref.key, ref.params)
|
|
29
|
+
})
|
|
30
|
+
const ageKey = (band: AgeBand): CommunityMessageKey => `onboarding.age.${band}`
|
|
31
|
+
|
|
32
|
+
// The focus goes to the step's heading on a new step and on a refusal; not while sending (the button keeps it).
|
|
33
|
+
// Steps read again after a refusal (a new text, a step added) are a new step too, at the same index or not.
|
|
34
|
+
watch(
|
|
35
|
+
() => [s.index, s.phase, s.steps.join()] as const,
|
|
36
|
+
([index, phase, steps], [indexBefore, , stepsBefore]) => {
|
|
37
|
+
if (index !== indexBefore || steps !== stepsBefore || phase === 'refused' || phase === 'failed') void nextTick(() => heading.value?.focus())
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
onMounted(() => void nextTick(() => heading.value?.focus()))
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<section class="bcm-card" :aria-labelledby="`${id}-title`">
|
|
45
|
+
<h3 :id="`${id}-title`" ref="heading" tabindex="-1">{{ t('onboarding.title') }}</h3>
|
|
46
|
+
|
|
47
|
+
<p v-if="s.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
48
|
+
|
|
49
|
+
<template v-else-if="s.phase === 'failed'">
|
|
50
|
+
<p class="bcm-error" role="alert">{{ failure }}</p>
|
|
51
|
+
<div class="bcm-row">
|
|
52
|
+
<button type="button" class="bcm-btn" @click="emit('cancel')">{{ t('common.cancel') }}</button>
|
|
53
|
+
<button type="button" class="bcm-btn bcm-primary" @click="model.load()">{{ t('common.retry') }}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<template v-else-if="s.phase === 'refused'">
|
|
58
|
+
<p role="alert">{{ t('onboarding.ageBelow', { min: s.minAge ?? 18 }) }}</p>
|
|
59
|
+
<div class="bcm-row">
|
|
60
|
+
<button type="button" class="bcm-btn" @click="emit('cancel')">{{ t('common.close') }}</button>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<form v-else class="bcm-form" novalidate @submit.prevent="model.next()">
|
|
65
|
+
<p v-if="s.index === 0 && fresh" class="bcm-hint">{{ t('onboarding.lede') }}</p>
|
|
66
|
+
|
|
67
|
+
<fieldset v-if="step === 'age'" class="bcm-choices">
|
|
68
|
+
<legend>{{ t('onboarding.ageTitle') }}</legend>
|
|
69
|
+
<label v-for="band in AGE_BANDS" :key="band" class="bcm-choice">
|
|
70
|
+
<input v-model="s.ageBand" type="radio" :name="`${id}-age`" :value="band" />
|
|
71
|
+
<span>{{ t(ageKey(band)) }}</span>
|
|
72
|
+
</label>
|
|
73
|
+
<p class="bcm-hint bcm-full">{{ t('onboarding.ageHint') }}</p>
|
|
74
|
+
</fieldset>
|
|
75
|
+
|
|
76
|
+
<div v-else-if="step === 'rules'" class="bcm-section">
|
|
77
|
+
<h4>{{ t('onboarding.rulesTitle') }}</h4>
|
|
78
|
+
<p>{{ s.reconsent ? t('onboarding.rulesChanged') : t('onboarding.rulesLede') }}</p>
|
|
79
|
+
<ol v-if="s.rules.length" class="bcm-rules">
|
|
80
|
+
<li v-for="rule in s.rules" :key="rule.id">
|
|
81
|
+
<strong>{{ rule.title }}</strong>
|
|
82
|
+
<span v-if="rule.summary" class="bcm-hint">{{ rule.summary }}</span>
|
|
83
|
+
</li>
|
|
84
|
+
</ol>
|
|
85
|
+
<p><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesLink') }}</a></p>
|
|
86
|
+
<label class="bcm-check">
|
|
87
|
+
<input v-model="s.rulesAccepted" type="checkbox" />
|
|
88
|
+
<span>{{ t('onboarding.rulesAccept') }}</span>
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div v-else-if="step === 'art9'" class="bcm-section">
|
|
93
|
+
<h4>{{ t('onboarding.art9Title') }}</h4>
|
|
94
|
+
<p>{{ t('onboarding.art9Lede') }}</p>
|
|
95
|
+
<div class="bcm-consent" tabindex="0" role="document" :aria-label="t('onboarding.art9Title')">
|
|
96
|
+
<p v-for="(paragraph, i) in s.art9Paragraphs" :key="i" class="bcm-text">{{ paragraph }}</p>
|
|
97
|
+
</div>
|
|
98
|
+
<label class="bcm-check">
|
|
99
|
+
<input v-model="s.art9Accepted" type="checkbox" />
|
|
100
|
+
<span>{{ t('onboarding.art9Accept') }}</span>
|
|
101
|
+
</label>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div v-else-if="step === 'profile'" class="bcm-section">
|
|
105
|
+
<h4>{{ t('onboarding.profileTitle') }}</h4>
|
|
106
|
+
<div class="bcm-field">
|
|
107
|
+
<label :for="`${id}-name`">{{ t('onboarding.displayName') }}</label>
|
|
108
|
+
<input
|
|
109
|
+
:id="`${id}-name`"
|
|
110
|
+
v-model="s.displayName"
|
|
111
|
+
class="bcm-input"
|
|
112
|
+
autocomplete="nickname"
|
|
113
|
+
:maxlength="COMMUNITY_DISPLAY_NAME_MAX * 2"
|
|
114
|
+
:aria-invalid="model.hasFieldError('displayName')"
|
|
115
|
+
:aria-describedby="`${id}-name-hint`"
|
|
116
|
+
/>
|
|
117
|
+
<p :id="`${id}-name-hint`" class="bcm-hint">{{ t('onboarding.displayNameHint') }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="bcm-field">
|
|
120
|
+
<label :for="`${id}-handle`">{{ t('onboarding.handle') }}</label>
|
|
121
|
+
<input
|
|
122
|
+
:id="`${id}-handle`"
|
|
123
|
+
v-model="s.handle"
|
|
124
|
+
class="bcm-input"
|
|
125
|
+
autocomplete="username"
|
|
126
|
+
autocapitalize="none"
|
|
127
|
+
spellcheck="false"
|
|
128
|
+
:maxlength="HANDLE_MAX"
|
|
129
|
+
:placeholder="model.suggestedHandle() ?? ''"
|
|
130
|
+
:aria-invalid="model.hasFieldError('handle')"
|
|
131
|
+
:aria-describedby="`${id}-handle-hint`"
|
|
132
|
+
/>
|
|
133
|
+
<p :id="`${id}-handle-hint`" class="bcm-hint">
|
|
134
|
+
{{ t('onboarding.handleHint', { min: HANDLE_MIN, max: HANDLE_MAX, days: COMMUNITY_HANDLE_CHANGE_DAYS }) }}
|
|
135
|
+
</p>
|
|
136
|
+
<p v-if="model.suggestedHandle()" class="bcm-hint">{{ t('onboarding.handleSuggested', { handle: model.suggestedHandle() ?? '' }) }}</p>
|
|
137
|
+
</div>
|
|
138
|
+
<fieldset class="bcm-choices">
|
|
139
|
+
<legend>{{ t('onboarding.avatar') }}</legend>
|
|
140
|
+
<label class="bcm-choice">
|
|
141
|
+
<input v-model="s.avatarPreset" type="radio" :name="`${id}-avatar`" :value="null" />
|
|
142
|
+
<span>{{ t('onboarding.avatarNone') }}</span>
|
|
143
|
+
</label>
|
|
144
|
+
<label v-for="preset in AVATAR_PRESETS" :key="preset.id" class="bcm-choice">
|
|
145
|
+
<input v-model="s.avatarPreset" type="radio" :name="`${id}-avatar`" :value="preset.id" />
|
|
146
|
+
<span>{{ preset.emoji }}</span>
|
|
147
|
+
</label>
|
|
148
|
+
</fieldset>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
152
|
+
|
|
153
|
+
<div class="bcm-row">
|
|
154
|
+
<button v-if="s.index > 0" type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="model.back()">{{ t('onboarding.back') }}</button>
|
|
155
|
+
<button type="button" class="bcm-btn" :disabled="s.phase === 'sending'" @click="emit('cancel')">{{ t('common.cancel') }}</button>
|
|
156
|
+
<span class="bcm-spacer" />
|
|
157
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!model.canContinue()">
|
|
158
|
+
{{ s.phase === 'sending' ? t('composer.sending') : model.isLast() ? t('onboarding.submit') : t('onboarding.continue') }}
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</form>
|
|
162
|
+
</section>
|
|
163
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Reactions from the tenant's set, counts only (who reacted is never shown in a public space). The viewer's
|
|
4
|
+
* own is a pressed button — a thicker border, a bold count and a spoken „Vaše reakce", not colour alone.
|
|
5
|
+
* A reader who cannot react sees the counts that are not zero.
|
|
6
|
+
*/
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
import type { ReactionSummary } from '@blogmcp/sdk/community'
|
|
9
|
+
import { useText } from './context'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
reactionSet: readonly string[]
|
|
13
|
+
summary: ReactionSummary
|
|
14
|
+
interactive: boolean
|
|
15
|
+
busy: boolean
|
|
16
|
+
}>()
|
|
17
|
+
const emit = defineEmits<{ react: [emoji: string] }>()
|
|
18
|
+
const { t } = useText()
|
|
19
|
+
|
|
20
|
+
const shown = computed(() => (props.interactive ? props.reactionSet : props.reactionSet.filter((e) => (props.summary.counts[e] ?? 0) > 0)))
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div v-if="shown.length" class="bcm-reactions" role="group" :aria-label="t('discussion.reactions')">
|
|
25
|
+
<template v-for="emoji in shown" :key="emoji">
|
|
26
|
+
<button
|
|
27
|
+
v-if="interactive"
|
|
28
|
+
type="button"
|
|
29
|
+
class="bcm-react"
|
|
30
|
+
:aria-pressed="summary.mine === emoji"
|
|
31
|
+
:disabled="busy"
|
|
32
|
+
:title="summary.mine === emoji ? t('discussion.reactionRemove') : t('discussion.reactionAdd')"
|
|
33
|
+
@click="emit('react', emoji)"
|
|
34
|
+
>
|
|
35
|
+
<span>{{ t('discussion.reactionCount', { emoji, count: summary.counts[emoji] ?? 0 }) }}</span>
|
|
36
|
+
<span v-if="summary.mine === emoji" class="bcm-sr">{{ t('discussion.reactionMine') }}</span>
|
|
37
|
+
</button>
|
|
38
|
+
<span v-else class="bcm-react">{{ t('discussion.reactionCount', { emoji, count: summary.counts[emoji] ?? 0 }) }}</span>
|
|
39
|
+
</template>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A member's report of a message (`messages/report`): the category (community.md §5's list), the rule of
|
|
4
|
+
* the tenant's rules text when the category is the rules, and the one free text — a note only the team
|
|
5
|
+
* reads; a link to the full rules (community.md §4.5, in a new tab: the dialog stays). The outcome comes back
|
|
6
|
+
* in „Moje komunita".
|
|
7
|
+
*/
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import { CASE_CATEGORIES, COMMUNITY_REPORT_NOTE_MAX, type CaseCategory, type CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
10
|
+
import type { ReportDraft } from '../core/discussion'
|
|
11
|
+
import { failureText } from '../core/outcomes'
|
|
12
|
+
import { codePointLength, normalizeBody } from '../core/text'
|
|
13
|
+
import { uid, useModal, useText } from './context'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{ report: ReportDraft; myCommunityHref: string; rulesHref?: string; fallbackFocus?: () => HTMLElement | null }>()
|
|
16
|
+
const emit = defineEmits<{ submit: []; close: [] }>()
|
|
17
|
+
const { t, lang } = useText()
|
|
18
|
+
|
|
19
|
+
const dialog = ref<HTMLDialogElement | null>(null)
|
|
20
|
+
const id = uid('bcm-report')
|
|
21
|
+
useModal(dialog, () => emit('close'), props.fallbackFocus)
|
|
22
|
+
|
|
23
|
+
const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
|
|
24
|
+
const noteLength = computed(() => codePointLength(normalizeBody(props.report.note)))
|
|
25
|
+
const canSend = computed(
|
|
26
|
+
() =>
|
|
27
|
+
!props.report.sending &&
|
|
28
|
+
props.report.category !== '' &&
|
|
29
|
+
(props.report.category !== 'rules' || props.report.ruleId !== '') &&
|
|
30
|
+
noteLength.value <= COMMUNITY_REPORT_NOTE_MAX,
|
|
31
|
+
)
|
|
32
|
+
const failure = computed(() => {
|
|
33
|
+
const f = props.report.failure
|
|
34
|
+
if (!f) return ''
|
|
35
|
+
const ref = failureText(f, lang.value)
|
|
36
|
+
return t(ref.key, ref.params)
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<dialog ref="dialog" class="bcm-dialog" :aria-labelledby="`${id}-title`">
|
|
42
|
+
<form class="bcm-dialog-body" novalidate @submit.prevent="canSend && emit('submit')">
|
|
43
|
+
<h3 :id="`${id}-title`">{{ t('report.title') }}</h3>
|
|
44
|
+
<template v-if="!report.done">
|
|
45
|
+
<p class="bcm-hint">{{ t('report.lede') }}</p>
|
|
46
|
+
<p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
|
|
47
|
+
<fieldset class="bcm-choices">
|
|
48
|
+
<legend>{{ t('report.category') }}</legend>
|
|
49
|
+
<label v-for="c in CASE_CATEGORIES" :key="c" class="bcm-choice">
|
|
50
|
+
<input v-model="report.category" type="radio" :name="`${id}-category`" :value="c" />
|
|
51
|
+
<span>{{ t(categoryKey(c)) }}</span>
|
|
52
|
+
</label>
|
|
53
|
+
</fieldset>
|
|
54
|
+
<div v-if="report.category === 'rules'" class="bcm-field">
|
|
55
|
+
<label :for="`${id}-rule`">{{ t('report.rule') }}</label>
|
|
56
|
+
<p v-if="report.rulesFailed" class="bcm-error">{{ t('error.unavailable') }}</p>
|
|
57
|
+
<p v-else-if="report.rules === null" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
58
|
+
<select v-else :id="`${id}-rule`" v-model="report.ruleId" class="bcm-input" required>
|
|
59
|
+
<option value="" disabled />
|
|
60
|
+
<option v-for="rule in report.rules" :key="rule.id" :value="rule.id">{{ rule.title }}</option>
|
|
61
|
+
</select>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="bcm-field">
|
|
64
|
+
<label :for="`${id}-note`">{{ t('report.note') }}</label>
|
|
65
|
+
<textarea :id="`${id}-note`" v-model="report.note" class="bcm-input bcm-textarea" rows="3" :aria-describedby="`${id}-note-hint`" :aria-invalid="noteLength > COMMUNITY_REPORT_NOTE_MAX" />
|
|
66
|
+
<p :id="`${id}-note-hint`" class="bcm-hint">
|
|
67
|
+
{{ t('report.noteHint') }}
|
|
68
|
+
{{ t('report.counter', { length: noteLength, max: COMMUNITY_REPORT_NOTE_MAX }) }}
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
72
|
+
<div class="bcm-row">
|
|
73
|
+
<span class="bcm-spacer" />
|
|
74
|
+
<button type="button" class="bcm-btn" :disabled="report.sending" @click="emit('close')">{{ t('report.cancel') }}</button>
|
|
75
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">{{ report.sending ? t('report.sending') : t('report.submit') }}</button>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
78
|
+
<template v-else>
|
|
79
|
+
<p role="status">{{ t('report.done') }}</p>
|
|
80
|
+
<p><a :href="myCommunityHref">{{ t('panel.title') }}</a></p>
|
|
81
|
+
<div class="bcm-row">
|
|
82
|
+
<span class="bcm-spacer" />
|
|
83
|
+
<button type="button" class="bcm-btn bcm-primary" @click="emit('close')">{{ t('common.close') }}</button>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
</form>
|
|
87
|
+
</dialog>
|
|
88
|
+
</template>
|