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