@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,378 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* `<blogmcp-my-community>`: the reader's „Moje komunita" (m1-contracts.md §10.4; community.md §5). Three
|
|
4
|
+
* pages in one element:
|
|
5
|
+
* - with `decision-id`: that statement of reasons and its appeal (the host's statement page);
|
|
6
|
+
* - with `#t=<receipt>` in the address: that notice's status and appeal — no sign-in needed; the receipt
|
|
7
|
+
* is read from this page's own address once and cleared from the address bar (core/receipt.ts);
|
|
8
|
+
* - otherwise the panel: profile, notifications, statements, reports, blocks, consents, export, deletion.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
|
|
11
|
+
import {
|
|
12
|
+
COMMUNITY_HANDLE_CHANGE_DAYS,
|
|
13
|
+
signInLink,
|
|
14
|
+
statementPath,
|
|
15
|
+
type CommunityMessageKey,
|
|
16
|
+
type CommunityTransport,
|
|
17
|
+
type NotificationItem,
|
|
18
|
+
} from '@blogmcp/sdk/community'
|
|
19
|
+
import { AVATAR_PRESETS } from '../core/avatars'
|
|
20
|
+
import { HANDLE_MAX, HANDLE_MIN } from '../core/names'
|
|
21
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
22
|
+
import { communityClientFor, siteBaseFor } from '../core/client'
|
|
23
|
+
import { exportFileName, saveJson } from '../core/download'
|
|
24
|
+
import { formatDate } from '../core/format'
|
|
25
|
+
import { documentLang, knownKey, pickLang, type Lang } from '../core/i18n'
|
|
26
|
+
import { failureText } from '../core/outcomes'
|
|
27
|
+
import { createPanel, type PanelController } from '../core/panel'
|
|
28
|
+
import { receiptForElement } from '../core/receipt'
|
|
29
|
+
import AuthorAvatar from './AuthorAvatar.vue'
|
|
30
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
31
|
+
import NoticeStatusView from './NoticeStatusView.vue'
|
|
32
|
+
import OnboardingSteps from './OnboardingSteps.vue'
|
|
33
|
+
import StatementView from './StatementView.vue'
|
|
34
|
+
import { provideText, uid } from './context'
|
|
35
|
+
|
|
36
|
+
defineOptions({ inheritAttrs: false })
|
|
37
|
+
|
|
38
|
+
const props = defineProps<{
|
|
39
|
+
apiBase?: string
|
|
40
|
+
/** The path the site is served under (`/blog`): its proxy and its pages, as the defaults. */
|
|
41
|
+
siteBase?: string
|
|
42
|
+
lang?: string
|
|
43
|
+
/** Opens this statement (the host's statement page). */
|
|
44
|
+
decisionId?: string
|
|
45
|
+
/** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
|
|
46
|
+
routes?: string | Record<string, unknown>
|
|
47
|
+
/** The site's sign-in page, for a reader without a session. */
|
|
48
|
+
signInHref?: string
|
|
49
|
+
transport?: CommunityTransport
|
|
50
|
+
}>()
|
|
51
|
+
|
|
52
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
53
|
+
const { t, text } = provideText(lang)
|
|
54
|
+
const id = uid('bcm-panel')
|
|
55
|
+
const heading = ref<HTMLElement | null>(null)
|
|
56
|
+
|
|
57
|
+
const siteBase = siteBaseFor(props.siteBase)
|
|
58
|
+
const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
|
|
59
|
+
const ctl: PanelController | null = client ? createPanel({ client, lang: lang.value }) : null
|
|
60
|
+
const state = ctl?.state ?? null
|
|
61
|
+
|
|
62
|
+
const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), state?.me?.links, siteBase ?? ''))
|
|
63
|
+
const decisionMode = computed(() => !!props.decisionId)
|
|
64
|
+
const noticeMode = ref(false)
|
|
65
|
+
const confirming = ref<'consent' | 'erase' | null>(null)
|
|
66
|
+
|
|
67
|
+
const host = useHost()
|
|
68
|
+
|
|
69
|
+
onMounted(() => {
|
|
70
|
+
if (!ctl) return
|
|
71
|
+
// Only a receipt this page's own address carried (or this element took before it was moved), never the tab's.
|
|
72
|
+
const receipt = receiptForElement(host)
|
|
73
|
+
if (receipt !== undefined) {
|
|
74
|
+
noticeMode.value = true
|
|
75
|
+
void ctl.loadNotice(receipt)
|
|
76
|
+
}
|
|
77
|
+
if (props.decisionId) void ctl.loadStatement(props.decisionId)
|
|
78
|
+
else void ctl.loadMember()
|
|
79
|
+
})
|
|
80
|
+
// A host that reuses the element for another statement (client-side navigation) reads that one.
|
|
81
|
+
watch(
|
|
82
|
+
() => props.decisionId,
|
|
83
|
+
(id, before) => {
|
|
84
|
+
if (!ctl || id === before) return
|
|
85
|
+
if (id) void ctl.loadStatement(id)
|
|
86
|
+
else if (state?.phase === 'idle') void ctl.loadMember()
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
onBeforeUnmount(() => ctl?.dispose())
|
|
90
|
+
|
|
91
|
+
const memberFailure = computed(() => {
|
|
92
|
+
const f = state?.failure
|
|
93
|
+
if (!f) return ''
|
|
94
|
+
const r = failureText(f, lang.value)
|
|
95
|
+
return t(r.key, r.params)
|
|
96
|
+
})
|
|
97
|
+
const statementFailure = computed(() => {
|
|
98
|
+
const f = state?.statement.failure
|
|
99
|
+
if (!f) return ''
|
|
100
|
+
const r = failureText(f, lang.value)
|
|
101
|
+
return t(r.key, r.params)
|
|
102
|
+
})
|
|
103
|
+
const noticeFailure = computed(() => {
|
|
104
|
+
const f = state?.notice.failure
|
|
105
|
+
if (!f) return ''
|
|
106
|
+
const r = failureText(f, lang.value)
|
|
107
|
+
return t(r.key, r.params)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
function signInHref(): string | null {
|
|
111
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
112
|
+
}
|
|
113
|
+
const statementHref = (decisionId: string): string | null => statementPath(routes.value.statementPath, decisionId)
|
|
114
|
+
const notificationKey = (n: NotificationItem): CommunityMessageKey | null => knownKey(`notification.${n.code}`)
|
|
115
|
+
const restrictionKey = (code: string): CommunityMessageKey | null => knownKey(`restriction.${code}`)
|
|
116
|
+
const categoryKey = (code: string): CommunityMessageKey | null => knownKey(`category.${code}`)
|
|
117
|
+
const caseStatusKey = (code: string): CommunityMessageKey | null => knownKey(`caseStatus.${code}`)
|
|
118
|
+
const appealStatusKey = (decisionId: string): CommunityMessageKey | null => {
|
|
119
|
+
const a = state?.appeals.find((x) => x.decisionId === decisionId)
|
|
120
|
+
return a ? knownKey(`appealStatus.${a.status}`) : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const consent = computed(() => state?.me?.acceptances.art9Consent ?? null)
|
|
124
|
+
const consentRefused = computed(() => state?.me?.acceptances.art9Refused === true)
|
|
125
|
+
const member = computed(() => state?.me?.member ?? null)
|
|
126
|
+
const profile = computed(() => state?.me?.profile ?? null)
|
|
127
|
+
|
|
128
|
+
function exportNow(): void {
|
|
129
|
+
void ctl?.exportData((data) => saveJson(data, exportFileName(t('panel.exportFileName'))))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function confirmAction(): Promise<void> {
|
|
133
|
+
if (!ctl) return
|
|
134
|
+
if (confirming.value === 'consent') await ctl.withdrawConsent()
|
|
135
|
+
else if (confirming.value === 'erase') await ctl.erase()
|
|
136
|
+
confirming.value = null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One live region for the outcomes (cleared in between, so the same one is announced again).
|
|
140
|
+
const liveText = ref('')
|
|
141
|
+
watch(
|
|
142
|
+
() => state?.announceId,
|
|
143
|
+
() => {
|
|
144
|
+
liveText.value = ''
|
|
145
|
+
const next = text(state?.announcement)
|
|
146
|
+
void nextTick(() => {
|
|
147
|
+
liveText.value = next
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<template>
|
|
154
|
+
<section v-if="ctl && state" class="bcm bcm-panel" :lang="lang" :aria-labelledby="`${id}-title`">
|
|
155
|
+
<h2 :id="`${id}-title`" ref="heading" tabindex="-1">{{ decisionMode ? t('statement.title') : t('panel.title') }}</h2>
|
|
156
|
+
|
|
157
|
+
<!-- the notifier's part (#t=) -->
|
|
158
|
+
<template v-if="noticeMode">
|
|
159
|
+
<p v-if="state.notice.phase === 'missing'" class="bcm-note" role="alert">{{ t('notice.receiptMissing') }}</p>
|
|
160
|
+
<p v-else-if="state.notice.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
161
|
+
<p v-else-if="state.notice.phase === 'failed'" class="bcm-error" role="alert">{{ noticeFailure }}</p>
|
|
162
|
+
<NoticeStatusView
|
|
163
|
+
v-else-if="state.notice.value"
|
|
164
|
+
:notice="state.notice.value"
|
|
165
|
+
:appeal="state.notice.appeal"
|
|
166
|
+
:withdrawing="!!state.busy.noticeWithdraw"
|
|
167
|
+
@appeal="ctl.fileNoticeAppeal()"
|
|
168
|
+
@withdraw="ctl.withdrawNoticeAppeal()"
|
|
169
|
+
/>
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<!-- the statement page (decision-id) -->
|
|
173
|
+
<template v-if="decisionMode">
|
|
174
|
+
<p v-if="state.statement.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
175
|
+
<div v-else-if="state.statement.phase === 'signIn'" class="bcm-row">
|
|
176
|
+
<p>{{ t('panel.signIn') }}</p>
|
|
177
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
178
|
+
</div>
|
|
179
|
+
<p v-else-if="state.statement.phase === 'failed'" class="bcm-error" role="alert">{{ statementFailure }}</p>
|
|
180
|
+
<StatementView
|
|
181
|
+
v-else-if="state.statement.value"
|
|
182
|
+
:statement="state.statement.value"
|
|
183
|
+
:appeal="state.statement.appeal"
|
|
184
|
+
:withdrawing="!!state.busy.withdraw"
|
|
185
|
+
@appeal="ctl.fileAppeal()"
|
|
186
|
+
@withdraw="ctl.withdrawAppeal()"
|
|
187
|
+
/>
|
|
188
|
+
<p><a :href="routes.myCommunityPath">{{ t('common.back') }}</a></p>
|
|
189
|
+
</template>
|
|
190
|
+
|
|
191
|
+
<!-- the panel -->
|
|
192
|
+
<template v-else>
|
|
193
|
+
<p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('common.loading') }}</p>
|
|
194
|
+
<div v-else-if="state.phase === 'signIn' && !noticeMode" class="bcm-row">
|
|
195
|
+
<p>{{ t('panel.signIn') }}</p>
|
|
196
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
197
|
+
</div>
|
|
198
|
+
<p v-else-if="state.phase === 'failed'" class="bcm-error" role="alert">{{ memberFailure }}</p>
|
|
199
|
+
<template v-else-if="state.phase === 'ready'">
|
|
200
|
+
<!-- profile -->
|
|
201
|
+
<section v-if="profile && member" class="bcm-section" :aria-labelledby="`${id}-profile`">
|
|
202
|
+
<h3 :id="`${id}-profile`">{{ t('panel.profile') }}</h3>
|
|
203
|
+
<div v-if="!state.profile" class="bcm-msg">
|
|
204
|
+
<AuthorAvatar :author="{ kind: 'member', memberId: member.memberId, displayName: profile.displayName, avatarPreset: profile.avatarPreset }" />
|
|
205
|
+
<div class="bcm-msg-main">
|
|
206
|
+
<span class="bcm-author">{{ profile.displayName }}</span>
|
|
207
|
+
<span class="bcm-handle">{{ profile.handle }}</span>
|
|
208
|
+
<div class="bcm-row">
|
|
209
|
+
<button type="button" class="bcm-btn bcm-small" @click="ctl.editProfile()">{{ t('panel.profileEdit') }}</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
<form v-else class="bcm-form bcm-card" novalidate @submit.prevent="ctl.saveProfile()">
|
|
214
|
+
<div class="bcm-field">
|
|
215
|
+
<label :for="`${id}-name`">{{ t('onboarding.displayName') }}</label>
|
|
216
|
+
<input :id="`${id}-name`" v-model="state.profile.displayName" class="bcm-input" autocomplete="nickname" :aria-invalid="state.profile.fields.includes('displayName')" />
|
|
217
|
+
<p class="bcm-hint">{{ t('onboarding.displayNameHint') }}</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="bcm-field">
|
|
220
|
+
<label :for="`${id}-handle`">{{ t('onboarding.handle') }}</label>
|
|
221
|
+
<input :id="`${id}-handle`" v-model="state.profile.handle" class="bcm-input" autocomplete="username" autocapitalize="none" spellcheck="false" :maxlength="HANDLE_MAX" :aria-invalid="state.profile.fields.includes('handle')" />
|
|
222
|
+
<p class="bcm-hint">{{ t('onboarding.handleHint', { min: HANDLE_MIN, max: HANDLE_MAX, days: COMMUNITY_HANDLE_CHANGE_DAYS }) }}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<fieldset class="bcm-choices">
|
|
225
|
+
<legend>{{ t('onboarding.avatar') }}</legend>
|
|
226
|
+
<label class="bcm-choice">
|
|
227
|
+
<input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="null" />
|
|
228
|
+
<span>{{ t('onboarding.avatarNone') }}</span>
|
|
229
|
+
</label>
|
|
230
|
+
<label v-for="preset in AVATAR_PRESETS" :key="preset.id" class="bcm-choice">
|
|
231
|
+
<input v-model="state.profile.avatarPreset" type="radio" :name="`${id}-avatar`" :value="preset.id" />
|
|
232
|
+
<span>{{ preset.emoji }}</span>
|
|
233
|
+
</label>
|
|
234
|
+
</fieldset>
|
|
235
|
+
<p v-if="state.profile.failure" class="bcm-error" role="alert">{{ text(failureText(state.profile.failure, lang)) }}</p>
|
|
236
|
+
<div class="bcm-row">
|
|
237
|
+
<span class="bcm-spacer" />
|
|
238
|
+
<button type="button" class="bcm-btn" :disabled="state.profile.sending" @click="ctl.cancelProfile()">{{ t('composer.cancel') }}</button>
|
|
239
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="state.profile.sending">{{ state.profile.sending ? t('composer.sending') : t('composer.save') }}</button>
|
|
240
|
+
</div>
|
|
241
|
+
</form>
|
|
242
|
+
</section>
|
|
243
|
+
|
|
244
|
+
<!-- notifications -->
|
|
245
|
+
<section class="bcm-section" :aria-labelledby="`${id}-notifications`">
|
|
246
|
+
<div class="bcm-row">
|
|
247
|
+
<h3 :id="`${id}-notifications`">{{ t('panel.notifications') }}</h3>
|
|
248
|
+
<span v-if="ctl.unreadCount() > 0" class="bcm-badge">{{ t('panel.unread', { count: ctl.unreadCount() }) }}</span>
|
|
249
|
+
<span class="bcm-spacer" />
|
|
250
|
+
<button v-if="ctl.unreadCount() > 0" type="button" class="bcm-btn bcm-small" :disabled="!!state.busy.markRead" @click="ctl.markAllRead()">{{ t('panel.markRead') }}</button>
|
|
251
|
+
</div>
|
|
252
|
+
<p v-if="state.notifications.length === 0" class="bcm-muted">{{ t('panel.notificationsEmpty') }}</p>
|
|
253
|
+
<ul v-else class="bcm-list">
|
|
254
|
+
<li v-for="n in state.notifications" :key="n.id" :class="{ 'bcm-unread': n.readAt === null }">
|
|
255
|
+
<p v-if="notificationKey(n)">{{ t(notificationKey(n)!) }}</p>
|
|
256
|
+
<time class="bcm-time" :datetime="n.createdAt">{{ formatDate(lang, n.createdAt) }}</time>
|
|
257
|
+
<a v-if="n.decisionId && statementHref(n.decisionId)" :href="statementHref(n.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
|
|
258
|
+
</li>
|
|
259
|
+
</ul>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<!-- statements -->
|
|
263
|
+
<section class="bcm-section" :aria-labelledby="`${id}-statements`">
|
|
264
|
+
<h3 :id="`${id}-statements`">{{ t('panel.statements') }}</h3>
|
|
265
|
+
<p v-if="state.decisions.length === 0" class="bcm-muted">{{ t('panel.statementsEmpty') }}</p>
|
|
266
|
+
<ul v-else class="bcm-list">
|
|
267
|
+
<li v-for="d in state.decisions" :key="d.decisionId">
|
|
268
|
+
<p v-if="restrictionKey(d.restriction)"><strong>{{ t(restrictionKey(d.restriction)!) }}</strong></p>
|
|
269
|
+
<p v-if="categoryKey(d.category)" class="bcm-muted">{{ t(categoryKey(d.category)!) }}</p>
|
|
270
|
+
<p class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, d.decidedAt) }) }}</p>
|
|
271
|
+
<p v-if="appealStatusKey(d.decisionId)" class="bcm-muted">{{ t(appealStatusKey(d.decisionId)!) }}</p>
|
|
272
|
+
<p v-else-if="d.canAppeal" class="bcm-muted">{{ t('statement.appealUntil', { date: formatDate(lang, d.appealWindowClosesAt) }) }}</p>
|
|
273
|
+
<a v-if="statementHref(d.decisionId)" :href="statementHref(d.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
|
|
274
|
+
</li>
|
|
275
|
+
</ul>
|
|
276
|
+
</section>
|
|
277
|
+
|
|
278
|
+
<!-- reports -->
|
|
279
|
+
<section class="bcm-section" :aria-labelledby="`${id}-reports`">
|
|
280
|
+
<h3 :id="`${id}-reports`">{{ t('panel.reports') }}</h3>
|
|
281
|
+
<p v-if="state.reports.length === 0" class="bcm-muted">{{ t('panel.reportsEmpty') }}</p>
|
|
282
|
+
<ul v-else class="bcm-list">
|
|
283
|
+
<li v-for="r in state.reports" :key="r.caseId">
|
|
284
|
+
<p v-if="categoryKey(r.category)"><strong>{{ t(categoryKey(r.category)!) }}</strong></p>
|
|
285
|
+
<p v-if="caseStatusKey(r.status)">{{ t(caseStatusKey(r.status)!) }}</p>
|
|
286
|
+
<time class="bcm-time" :datetime="r.reportedAt">{{ formatDate(lang, r.reportedAt) }}</time>
|
|
287
|
+
<p v-if="r.decidedAt" class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, r.decidedAt) }) }}</p>
|
|
288
|
+
</li>
|
|
289
|
+
</ul>
|
|
290
|
+
</section>
|
|
291
|
+
|
|
292
|
+
<!-- blocks -->
|
|
293
|
+
<section v-if="member" class="bcm-section" :aria-labelledby="`${id}-blocks`">
|
|
294
|
+
<h3 :id="`${id}-blocks`">{{ t('panel.blocks') }}</h3>
|
|
295
|
+
<p v-if="state.blocks.length === 0" class="bcm-muted">{{ t('panel.blocksEmpty') }}</p>
|
|
296
|
+
<ul v-else class="bcm-list">
|
|
297
|
+
<li v-for="b in state.blocks" :key="b.memberId" class="bcm-row">
|
|
298
|
+
<span>{{ b.author.kind === 'member' && 'displayName' in b.author && b.author.displayName ? b.author.displayName : t('author.deleted') }}</span>
|
|
299
|
+
<span class="bcm-spacer" />
|
|
300
|
+
<button type="button" class="bcm-btn bcm-small" :disabled="!!state.busy[`block:${b.memberId}`]" @click="ctl.unblock(b.memberId)">{{ t('discussion.unblock') }}</button>
|
|
301
|
+
</li>
|
|
302
|
+
</ul>
|
|
303
|
+
</section>
|
|
304
|
+
|
|
305
|
+
<!-- consents -->
|
|
306
|
+
<section v-if="member && consent !== null" class="bcm-section" :aria-labelledby="`${id}-consents`">
|
|
307
|
+
<h3 :id="`${id}-consents`">{{ t('panel.consents') }}</h3>
|
|
308
|
+
<template v-if="consent === 'accepted'">
|
|
309
|
+
<p>{{ t('panel.art9Given') }}</p>
|
|
310
|
+
<div class="bcm-row">
|
|
311
|
+
<button type="button" class="bcm-btn" :disabled="!!state.busy.consent" @click="confirming = 'consent'">{{ t('panel.art9Withdraw') }}</button>
|
|
312
|
+
</div>
|
|
313
|
+
</template>
|
|
314
|
+
<template v-else-if="consentRefused">
|
|
315
|
+
<p>{{ t('panel.art9Refused') }}</p>
|
|
316
|
+
<OnboardingSteps v-if="ctl.consent.value" :model="ctl.consent.value" :rules-href="routes.rulesPath" @cancel="ctl.closeConsent()" />
|
|
317
|
+
<div v-else class="bcm-row">
|
|
318
|
+
<button type="button" class="bcm-btn bcm-primary" @click="ctl.giveConsent()">{{ t('discussion.giveConsent') }}</button>
|
|
319
|
+
</div>
|
|
320
|
+
</template>
|
|
321
|
+
<p v-else>{{ t('panel.art9NotGiven') }}</p>
|
|
322
|
+
</section>
|
|
323
|
+
|
|
324
|
+
<!-- export -->
|
|
325
|
+
<section v-if="member" class="bcm-section" :aria-labelledby="`${id}-export`">
|
|
326
|
+
<h3 :id="`${id}-export`">{{ t('panel.export') }}</h3>
|
|
327
|
+
<p class="bcm-hint">{{ t('panel.exportHint') }}</p>
|
|
328
|
+
<div class="bcm-row">
|
|
329
|
+
<button type="button" class="bcm-btn" :disabled="state.exporting" @click="exportNow()">{{ state.exporting ? t('panel.exporting') : t('panel.export') }}</button>
|
|
330
|
+
</div>
|
|
331
|
+
</section>
|
|
332
|
+
|
|
333
|
+
<!-- the profile's deletion -->
|
|
334
|
+
<section v-if="member" class="bcm-section" :aria-labelledby="`${id}-erase`">
|
|
335
|
+
<h3 :id="`${id}-erase`">{{ t('panel.erase') }}</h3>
|
|
336
|
+
<template v-if="ctl.erasing()">
|
|
337
|
+
<p>{{ state.purgeAfter ? t('panel.eraseScheduled', { date: formatDate(lang, state.purgeAfter) }) : t('notification.erasure_scheduled') }}</p>
|
|
338
|
+
<div class="bcm-row">
|
|
339
|
+
<button type="button" class="bcm-btn bcm-primary" :disabled="!!state.busy.erase" @click="ctl.cancelErase()">{{ t('panel.eraseCancel') }}</button>
|
|
340
|
+
</div>
|
|
341
|
+
</template>
|
|
342
|
+
<template v-else>
|
|
343
|
+
<p class="bcm-hint">{{ t('panel.eraseExplain') }}</p>
|
|
344
|
+
<div class="bcm-row">
|
|
345
|
+
<button type="button" class="bcm-btn bcm-danger" :disabled="!!state.busy.erase" @click="confirming = 'erase'">{{ t('panel.erase') }}</button>
|
|
346
|
+
</div>
|
|
347
|
+
</template>
|
|
348
|
+
</section>
|
|
349
|
+
</template>
|
|
350
|
+
</template>
|
|
351
|
+
|
|
352
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
353
|
+
|
|
354
|
+
<ConfirmDialog
|
|
355
|
+
v-if="confirming === 'consent'"
|
|
356
|
+
title="panel.art9WithdrawTitle"
|
|
357
|
+
explain="panel.art9WithdrawExplain"
|
|
358
|
+
confirm="panel.art9WithdrawConfirm"
|
|
359
|
+
cancel="panel.art9WithdrawCancel"
|
|
360
|
+
:busy="!!state.busy.consent"
|
|
361
|
+
:fallback-focus="() => heading"
|
|
362
|
+
@confirm="confirmAction()"
|
|
363
|
+
@cancel="confirming = null"
|
|
364
|
+
/>
|
|
365
|
+
<ConfirmDialog
|
|
366
|
+
v-if="confirming === 'erase'"
|
|
367
|
+
title="panel.eraseTitle"
|
|
368
|
+
explain="panel.eraseExplain"
|
|
369
|
+
confirm="panel.eraseConfirm"
|
|
370
|
+
cancel="panel.eraseKeep"
|
|
371
|
+
danger
|
|
372
|
+
:busy="!!state.busy.erase"
|
|
373
|
+
:fallback-focus="() => heading"
|
|
374
|
+
@confirm="confirmAction()"
|
|
375
|
+
@cancel="confirming = null"
|
|
376
|
+
/>
|
|
377
|
+
</section>
|
|
378
|
+
</template>
|
|
@@ -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>
|