@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,294 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A refused age band, per element: kb-api keeps nothing of it (§7.4.2), so the element does, for its whole life —
|
|
4
|
+
* a discussion started again (another language) and the element put back by its host included.
|
|
5
|
+
*/
|
|
6
|
+
const refusedAges = new WeakMap<object, number>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
/**
|
|
11
|
+
* `<blogmcp-discussion>`: the discussion of one article (m1-contracts.md §10.4). Lives in a shadow root; talks
|
|
12
|
+
* only to the host's relative `api-base` through the SDK client; renders nothing when discussions are off,
|
|
13
|
+
* and one sentence while the discussion takes no writes yet.
|
|
14
|
+
*/
|
|
15
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
|
|
16
|
+
import { signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
|
|
17
|
+
import { parseRoutes, routesWithDefaults } from '../core/attrs'
|
|
18
|
+
import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
|
|
19
|
+
import { createDiscussionController, type DiscussionController } from '../core/discussion'
|
|
20
|
+
import { formatDate } from '../core/format'
|
|
21
|
+
import { documentLang, pickLang, type Lang } from '../core/i18n'
|
|
22
|
+
import { failureText } from '../core/outcomes'
|
|
23
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
24
|
+
import MessageComposer from './MessageComposer.vue'
|
|
25
|
+
import MessageItem from './MessageItem.vue'
|
|
26
|
+
import OnboardingSteps from './OnboardingSteps.vue'
|
|
27
|
+
import ReactionBar from './ReactionBar.vue'
|
|
28
|
+
import ReportDialog from './ReportDialog.vue'
|
|
29
|
+
import { provideText, uid } from './context'
|
|
30
|
+
import { provideDiscussion } from './discussion-context'
|
|
31
|
+
|
|
32
|
+
defineOptions({ inheritAttrs: false })
|
|
33
|
+
|
|
34
|
+
const props = defineProps<{
|
|
35
|
+
articleId?: string
|
|
36
|
+
/** The host's relative base (default `${siteBase}/api/community`). */
|
|
37
|
+
apiBase?: string
|
|
38
|
+
/** The path the site is served under (`/blog` on a managed blog): its proxy and its pages, as the defaults. */
|
|
39
|
+
siteBase?: string
|
|
40
|
+
lang?: string
|
|
41
|
+
/** The site's sign-in page; the current path goes in its fragment. */
|
|
42
|
+
signInHref?: string
|
|
43
|
+
/** JSON (or an object): `statementPath`, `myCommunityPath`, `rulesPath`, `reportPath`, `noticesPath`. */
|
|
44
|
+
routes?: string | Record<string, unknown>
|
|
45
|
+
/** Head poll interval in ms (default 20 000; 0 = off). */
|
|
46
|
+
pollMs?: number
|
|
47
|
+
/** A replacement transport (a property, never an attribute): tests, a signing transport later. */
|
|
48
|
+
transport?: CommunityTransport
|
|
49
|
+
}>()
|
|
50
|
+
|
|
51
|
+
const host = useHost()
|
|
52
|
+
const lang = ref<Lang>(pickLang(props.lang, documentLang()))
|
|
53
|
+
const { t, text } = provideText(lang)
|
|
54
|
+
watch(
|
|
55
|
+
() => props.lang,
|
|
56
|
+
(v) => {
|
|
57
|
+
lang.value = pickLang(v, documentLang())
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const headingId = uid('bcm-discussion')
|
|
62
|
+
const heading = ref<HTMLElement | null>(null)
|
|
63
|
+
const generation = ref(0)
|
|
64
|
+
const current = shallowRef<DiscussionController | null>(null)
|
|
65
|
+
const siteBase = computed(() => siteBaseFor(props.siteBase))
|
|
66
|
+
|
|
67
|
+
function create(): DiscussionController | null {
|
|
68
|
+
const articleId = props.articleId?.trim()
|
|
69
|
+
if (!articleId) {
|
|
70
|
+
warnOnce('article-id', '[blogmcp-community] <blogmcp-discussion> needs an article-id')
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
if (siteBase.value === null) return null
|
|
74
|
+
const client = communityClientFor(props.apiBase, props.transport, siteBase.value)
|
|
75
|
+
if (!client) return null
|
|
76
|
+
return createDiscussionController({
|
|
77
|
+
client,
|
|
78
|
+
articleId,
|
|
79
|
+
lang: lang.value,
|
|
80
|
+
pollMs: props.pollMs,
|
|
81
|
+
refusedAge: (host && refusedAges.get(host)) ?? null,
|
|
82
|
+
onAgeRefused: (minAge) => {
|
|
83
|
+
if (host) refusedAges.set(host, minAge)
|
|
84
|
+
},
|
|
85
|
+
onPosted: (state) => host?.dispatchEvent(new CustomEvent('bcm-posted', { detail: { state }, bubbles: true, composed: true })),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const opened = reactive(new Set<number>())
|
|
90
|
+
const originals = reactive(new Set<number>())
|
|
91
|
+
const allReplies = reactive(new Set<number>())
|
|
92
|
+
const deleting = ref<number | null>(null)
|
|
93
|
+
|
|
94
|
+
const parsedRoutes = computed(() => parseRoutes(props.routes))
|
|
95
|
+
const routes = computed(() => routesWithDefaults(parsedRoutes.value, current.value?.state.me?.links, siteBase.value ?? ''))
|
|
96
|
+
|
|
97
|
+
provideDiscussion({
|
|
98
|
+
get ctl() {
|
|
99
|
+
return current.value as DiscussionController
|
|
100
|
+
},
|
|
101
|
+
routes,
|
|
102
|
+
opened,
|
|
103
|
+
originals,
|
|
104
|
+
allReplies,
|
|
105
|
+
focusHeading: () => heading.value,
|
|
106
|
+
deleteMessage: (seq) => {
|
|
107
|
+
deleting.value = seq
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
let observer: IntersectionObserver | null = null
|
|
112
|
+
|
|
113
|
+
function start(): void {
|
|
114
|
+
current.value?.dispose()
|
|
115
|
+
opened.clear()
|
|
116
|
+
originals.clear()
|
|
117
|
+
allReplies.clear()
|
|
118
|
+
deleting.value = null
|
|
119
|
+
current.value = create()
|
|
120
|
+
generation.value += 1
|
|
121
|
+
void current.value?.start()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onMounted(() => {
|
|
125
|
+
start()
|
|
126
|
+
if (host && typeof IntersectionObserver !== 'undefined') {
|
|
127
|
+
observer = new IntersectionObserver((entries) => {
|
|
128
|
+
const e = entries[entries.length - 1]
|
|
129
|
+
if (e) current.value?.setInView(e.isIntersecting)
|
|
130
|
+
}, { rootMargin: '200px' })
|
|
131
|
+
observer.observe(host)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
// Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
|
|
135
|
+
watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.pollMs, lang.value] as const, () => start())
|
|
136
|
+
onBeforeUnmount(() => {
|
|
137
|
+
observer?.disconnect()
|
|
138
|
+
current.value?.dispose()
|
|
139
|
+
current.value = null
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const ctl = computed(() => current.value)
|
|
143
|
+
const state = computed(() => current.value?.state ?? null)
|
|
144
|
+
const mode = computed(() => current.value?.mode() ?? 'pending')
|
|
145
|
+
const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
|
|
146
|
+
// Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
|
|
147
|
+
// (the onboarding's button the focus was on is gone).
|
|
148
|
+
watch(mode, (now, before) => {
|
|
149
|
+
if (now === 'write' && before === 'onboarding') void nextTick(() => composer.value?.focus())
|
|
150
|
+
if (now === 'ageRefused' && before === 'onboarding') void nextTick(() => heading.value?.focus())
|
|
151
|
+
})
|
|
152
|
+
const needKey = computed(() => {
|
|
153
|
+
const n = current.value?.needs()
|
|
154
|
+
return n?.onboarding ? 'need.onboarding' : n?.rules ? 'need.reconsent_rules' : 'need.art9_consent'
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
function signInHref(): string | null {
|
|
158
|
+
return signInLink(props.signInHref, typeof location === 'undefined' ? null : location.pathname)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sanctionText(r: DiscussionRestriction): string {
|
|
162
|
+
const date = r.endsAt ? formatDate(lang.value, r.endsAt) : ''
|
|
163
|
+
if (r.kind === 'ban') return t('sanction.ban')
|
|
164
|
+
if (r.kind === 'suspension') return date ? t('sanction.suspensionUntil', { date }) : t('sanction.suspension')
|
|
165
|
+
return date ? t('sanction.restrictionUntil', { date }) : t('sanction.restriction')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const loadFailure = computed(() => {
|
|
169
|
+
const f = state.value?.failure
|
|
170
|
+
if (!f) return ''
|
|
171
|
+
const r = failureText(f, lang.value)
|
|
172
|
+
return t(r.key, r.params)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// One live region, present from the start; the same outcome twice is announced twice (cleared in between).
|
|
176
|
+
const liveText = ref('')
|
|
177
|
+
watch(
|
|
178
|
+
() => state.value?.announceId,
|
|
179
|
+
() => {
|
|
180
|
+
liveText.value = ''
|
|
181
|
+
const next = text(state.value?.announcement)
|
|
182
|
+
void nextTick(() => {
|
|
183
|
+
liveText.value = next
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
async function confirmDelete(): Promise<void> {
|
|
189
|
+
const seq = deleting.value
|
|
190
|
+
if (seq === null || !current.value) return
|
|
191
|
+
await current.value.remove(seq)
|
|
192
|
+
deleting.value = null
|
|
193
|
+
}
|
|
194
|
+
</script>
|
|
195
|
+
|
|
196
|
+
<template>
|
|
197
|
+
<section v-if="ctl && state && state.phase !== 'hidden'" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
|
|
198
|
+
<header class="bcm-head">
|
|
199
|
+
<h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
|
|
200
|
+
<span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
|
|
201
|
+
</header>
|
|
202
|
+
|
|
203
|
+
<p v-if="state.phase === 'loading'" class="bcm-muted" role="status">{{ t('discussion.loading') }}</p>
|
|
204
|
+
|
|
205
|
+
<div v-else-if="state.phase === 'failed'" class="bcm-banner" role="alert">
|
|
206
|
+
<span>{{ loadFailure }}</span>
|
|
207
|
+
<button type="button" class="bcm-btn" @click="ctl.refresh()">{{ t('common.retry') }}</button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<template v-else>
|
|
211
|
+
<ReactionBar
|
|
212
|
+
:reaction-set="state.thread.reactionSet"
|
|
213
|
+
:summary="state.thread.articleReactions"
|
|
214
|
+
:interactive="ctl.canReact()"
|
|
215
|
+
:busy="!!state.busy['react:article']"
|
|
216
|
+
@react="(emoji: string) => ctl && ctl.react({ articleId: state!.thread.articleId ?? articleId ?? '' }, emoji)"
|
|
217
|
+
/>
|
|
218
|
+
|
|
219
|
+
<div v-if="state.newMessages" class="bcm-banner" role="status">
|
|
220
|
+
<span>{{ t('discussion.newMessages') }}</span>
|
|
221
|
+
<button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
|
|
225
|
+
<ol v-else class="bcm-thread">
|
|
226
|
+
<MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
|
|
227
|
+
</ol>
|
|
228
|
+
|
|
229
|
+
<div v-if="state.thread.nextCursor !== null" class="bcm-row">
|
|
230
|
+
<button type="button" class="bcm-btn" :disabled="state.loadingMore" @click="ctl.loadMore()">{{ t('discussion.loadMore') }}</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="bcm-section">
|
|
234
|
+
<MessageComposer
|
|
235
|
+
v-if="mode === 'write'"
|
|
236
|
+
ref="composer"
|
|
237
|
+
kind="new"
|
|
238
|
+
:draft="state.composer"
|
|
239
|
+
:wait="ctl.waitLeft(state.composer)"
|
|
240
|
+
:rules-href="routes.rulesPath"
|
|
241
|
+
@submit="ctl.submit()"
|
|
242
|
+
@confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
|
|
243
|
+
@confirmed="ctl.sendConfirmed(state.composer)"
|
|
244
|
+
/>
|
|
245
|
+
<div v-else-if="mode === 'signIn'" class="bcm-row">
|
|
246
|
+
<p>{{ t('discussion.signInPrompt') }}</p>
|
|
247
|
+
<a v-if="signInHref()" class="bcm-btn bcm-primary" :href="signInHref() ?? undefined">{{ t('common.signIn') }}</a>
|
|
248
|
+
</div>
|
|
249
|
+
<template v-else-if="mode === 'onboarding'">
|
|
250
|
+
<OnboardingSteps v-if="ctl.onboarding.value" :model="ctl.onboarding.value" :rules-href="routes.rulesPath" @cancel="ctl.cancelOnboarding()" />
|
|
251
|
+
<div v-else class="bcm-row">
|
|
252
|
+
<p>{{ t(needKey) }}</p>
|
|
253
|
+
<button type="button" class="bcm-btn bcm-primary" @click="ctl.beginOnboarding()">{{ t('onboarding.continue') }}</button>
|
|
254
|
+
</div>
|
|
255
|
+
</template>
|
|
256
|
+
<p v-else-if="mode === 'ageRefused'" class="bcm-note">{{ t('onboarding.ageBelow', { min: state.refusedAge ?? 18 }) }}</p>
|
|
257
|
+
<div v-else-if="mode === 'sanction'" class="bcm-section">
|
|
258
|
+
<p v-for="r in state.thread.viewer.restrictions" :key="r.decisionId" class="bcm-note">
|
|
259
|
+
{{ sanctionText(r) }}
|
|
260
|
+
<a v-if="statementPath(routes.statementPath, r.decisionId)" :href="statementPath(routes.statementPath, r.decisionId) ?? undefined">{{ t('sanction.statement') }}</a>
|
|
261
|
+
</p>
|
|
262
|
+
</div>
|
|
263
|
+
<p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
|
|
264
|
+
<p v-else-if="mode === 'restricted'" class="bcm-note">
|
|
265
|
+
{{ t('error.restricted') }}
|
|
266
|
+
<a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
</template>
|
|
270
|
+
|
|
271
|
+
<p class="bcm-sr" aria-live="polite">{{ liveText }}</p>
|
|
272
|
+
|
|
273
|
+
<ReportDialog
|
|
274
|
+
v-if="state.report"
|
|
275
|
+
:report="state.report"
|
|
276
|
+
:my-community-href="routes.myCommunityPath"
|
|
277
|
+
:rules-href="routes.rulesPath"
|
|
278
|
+
:fallback-focus="() => heading"
|
|
279
|
+
@submit="ctl.submitReport()"
|
|
280
|
+
@close="ctl.closeReport()"
|
|
281
|
+
/>
|
|
282
|
+
<ConfirmDialog
|
|
283
|
+
v-if="deleting !== null"
|
|
284
|
+
title="discussion.deleteTitle"
|
|
285
|
+
explain="discussion.deleteExplain"
|
|
286
|
+
confirm="discussion.deleteConfirm"
|
|
287
|
+
danger
|
|
288
|
+
:busy="!!state.busy[`delete:${deleting}`]"
|
|
289
|
+
:fallback-focus="() => heading"
|
|
290
|
+
@confirm="confirmDelete()"
|
|
291
|
+
@cancel="deleting = null"
|
|
292
|
+
/>
|
|
293
|
+
</section>
|
|
294
|
+
</template>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A member's text, as text: runs of text nodes, and the web addresses in it as links that went through
|
|
4
|
+
* sanitizeUrl (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab. Never HTML.
|
|
5
|
+
*/
|
|
6
|
+
import { computed } from 'vue'
|
|
7
|
+
import { linkify, UGC_LINK_REL } from '../core/text'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ text: string | null }>()
|
|
10
|
+
const segments = computed(() => linkify(props.text ?? ''))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<template v-for="(s, i) in segments" :key="i">
|
|
15
|
+
<a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
|
|
16
|
+
<template v-else>{{ s.text }}</template>
|
|
17
|
+
</template>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The composer of a new message, a reply or an edit: 4 000 characters with a counter (announced near the
|
|
4
|
+
* end), the links note, a link to the rules (community.md §4.5: by every composer; a new tab, so the text
|
|
5
|
+
* stays), Cmd/Ctrl+Enter, the cooldown after TOO_FAST or a quota, and the Russmedia confirmation when kb-api
|
|
6
|
+
* asks for one (the same text goes again with `confirmations`).
|
|
7
|
+
*/
|
|
8
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
9
|
+
import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, confirmMessageKey } from '@blogmcp/sdk/community'
|
|
10
|
+
import type { Draft } from '../core/discussion'
|
|
11
|
+
import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
|
|
12
|
+
import { codePointLength, normalizeBody } from '../core/text'
|
|
13
|
+
import { uid, useText } from './context'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
draft: Draft
|
|
17
|
+
kind: 'new' | 'reply' | 'edit'
|
|
18
|
+
/** Seconds the draft still waits (the controller's clock). */
|
|
19
|
+
wait: number
|
|
20
|
+
replyingTo?: string | null
|
|
21
|
+
/** The host's rules page. */
|
|
22
|
+
rulesHref?: string
|
|
23
|
+
}>()
|
|
24
|
+
const emit = defineEmits<{ submit: []; cancel: []; confirmed: []; confirm: [code: string, checked: boolean] }>()
|
|
25
|
+
const { t, lang } = useText()
|
|
26
|
+
|
|
27
|
+
const id = uid('bcm-composer')
|
|
28
|
+
const input = ref<HTMLTextAreaElement | null>(null)
|
|
29
|
+
const length = computed(() => codePointLength(normalizeBody(props.draft.body)))
|
|
30
|
+
const over = computed(() => length.value > COMMUNITY_MESSAGE_MAX)
|
|
31
|
+
const canSend = computed(() => !props.draft.sending && length.value > 0 && !over.value && props.wait === 0 && props.draft.confirm === null)
|
|
32
|
+
const allConfirmed = computed(() => {
|
|
33
|
+
const c = props.draft.confirm
|
|
34
|
+
return c !== null && c.codes.every((code) => c.checked.includes(code))
|
|
35
|
+
})
|
|
36
|
+
const label = computed(() => t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : 'composer.label'))
|
|
37
|
+
const keys = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘ + Enter' : 'Ctrl + Enter'
|
|
38
|
+
const failure = computed(() => {
|
|
39
|
+
const f = props.draft.failure
|
|
40
|
+
// The confirmation box says what CONFIRMATION_REQUIRED meant; any later refusal (the confirmed send's) shows.
|
|
41
|
+
if (!f || (props.draft.confirm && f.code === 'confirm')) return ''
|
|
42
|
+
// A short wait reads as the countdown instead.
|
|
43
|
+
if ((f.code === 'fast' || f.code === 'quota') && props.wait > 0 && props.wait <= COUNTDOWN_MAX_S) return ''
|
|
44
|
+
const ref = failureText(f, lang.value)
|
|
45
|
+
return t(ref.key, ref.params)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// The counter is read on focus (aria-describedby); near the limit it is also announced, once typing pauses.
|
|
49
|
+
const counterLive = ref('')
|
|
50
|
+
let liveTimer: ReturnType<typeof setTimeout> | null = null
|
|
51
|
+
watch(length, (n) => {
|
|
52
|
+
if (liveTimer) clearTimeout(liveTimer)
|
|
53
|
+
liveTimer = setTimeout(() => {
|
|
54
|
+
counterLive.value = n >= COMMUNITY_MESSAGE_MAX * 0.9 ? t('composer.counter', { length: n, max: COMMUNITY_MESSAGE_MAX }) : ''
|
|
55
|
+
}, 700)
|
|
56
|
+
})
|
|
57
|
+
onBeforeUnmount(() => {
|
|
58
|
+
if (liveTimer) clearTimeout(liveTimer)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// A changed text is a new attempt: the old refusal and the confirmation it asked for no longer apply
|
|
62
|
+
// (a cooldown still does).
|
|
63
|
+
watch(
|
|
64
|
+
() => props.draft.body,
|
|
65
|
+
() => {
|
|
66
|
+
const d = props.draft
|
|
67
|
+
if (d.confirm) d.confirm = null
|
|
68
|
+
if (d.failure && d.failure.code !== 'fast' && d.failure.code !== 'quota') d.failure = null
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
function onKey(e: KeyboardEvent): void {
|
|
73
|
+
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return
|
|
74
|
+
e.preventDefault()
|
|
75
|
+
if (canSend.value) emit('submit')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onSubmit(): void {
|
|
79
|
+
if (canSend.value) emit('submit')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
defineExpose({ focus: () => input.value?.focus() })
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<form class="bcm-composer" @submit.prevent="onSubmit">
|
|
87
|
+
<label :for="id" class="bcm-label">{{ label }}</label>
|
|
88
|
+
<p v-if="replyingTo" class="bcm-hint">{{ t('composer.replyingTo', { name: replyingTo }) }}</p>
|
|
89
|
+
<textarea
|
|
90
|
+
:id="id"
|
|
91
|
+
ref="input"
|
|
92
|
+
v-model="draft.body"
|
|
93
|
+
class="bcm-input bcm-textarea"
|
|
94
|
+
rows="4"
|
|
95
|
+
:placeholder="t('composer.placeholder')"
|
|
96
|
+
:aria-describedby="`${id}-counter ${id}-hints`"
|
|
97
|
+
:aria-invalid="over"
|
|
98
|
+
:readonly="draft.sending"
|
|
99
|
+
@keydown="onKey"
|
|
100
|
+
/>
|
|
101
|
+
<div class="bcm-composer-foot">
|
|
102
|
+
<span :id="`${id}-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': over }">{{ t('composer.counter', { length, max: COMMUNITY_MESSAGE_MAX }) }}</span>
|
|
103
|
+
<span class="bcm-sr" aria-live="polite">{{ counterLive }}</span>
|
|
104
|
+
<span class="bcm-spacer" />
|
|
105
|
+
<button v-if="kind !== 'new'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
|
|
106
|
+
<button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">
|
|
107
|
+
{{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : t('composer.submit') }}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div :id="`${id}-hints`" class="bcm-hint">
|
|
111
|
+
<p>{{ t('composer.linksNote', { count: COMMUNITY_MESSAGE_LINKS_MAX }) }}</p>
|
|
112
|
+
<p>{{ t('composer.shortcut', { keys }) }}</p>
|
|
113
|
+
<p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
|
|
114
|
+
</div>
|
|
115
|
+
<p v-if="over" class="bcm-error">{{ t('composer.tooLong', { max: COMMUNITY_MESSAGE_MAX }) }}</p>
|
|
116
|
+
<p v-if="wait > 0 && wait <= COUNTDOWN_MAX_S" class="bcm-warning" role="status">{{ t('composer.cooldown', { count: wait }) }}</p>
|
|
117
|
+
<p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
|
|
118
|
+
<fieldset v-if="draft.confirm" class="bcm-confirm">
|
|
119
|
+
<legend class="bcm-label">{{ t('confirm.title') }}</legend>
|
|
120
|
+
<p>{{ t('error.confirm') }}</p>
|
|
121
|
+
<label v-for="code in draft.confirm.codes" :key="code" class="bcm-check">
|
|
122
|
+
<input type="checkbox" :checked="draft.confirm.checked.includes(code)" @change="emit('confirm', code, ($event.target as HTMLInputElement).checked)" />
|
|
123
|
+
<span>{{ t(confirmMessageKey(code)) }}</span>
|
|
124
|
+
</label>
|
|
125
|
+
<div class="bcm-row">
|
|
126
|
+
<button type="button" class="bcm-btn bcm-primary" :disabled="!allConfirmed || draft.sending || wait > 0" @click="emit('confirmed')">{{ t('confirm.submit') }}</button>
|
|
127
|
+
</div>
|
|
128
|
+
</fieldset>
|
|
129
|
+
</form>
|
|
130
|
+
</template>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One message of the thread, and on a top-level message its replies (two levels). What it shows depends on
|
|
4
|
+
* its state, by community.md §5 (no shadow bans): the author sees a held or removed message WITH its state
|
|
5
|
+
* („Čeká na kontrolu", and for a removed one the way to the statement in „Moje komunita"); others see a
|
|
6
|
+
* placeholder, which kb-api sends only while replies hang on it; a blocked author's message is collapsed
|
|
7
|
+
* until the reader opens it.
|
|
8
|
+
*
|
|
9
|
+
* The author's own message the tenant's art. 9 policy keeps from others until they consent (`hiddenUntilConsent`
|
|
10
|
+
* on a `held` message, m1-contracts.md §7.14 P-RV3) is not awaiting review: it says so („Čeká na váš souhlas"),
|
|
11
|
+
* and while the discussion asks this member for the consent it offers the onboarding's consent step in place
|
|
12
|
+
* (`beginOnboarding`, the composer's own). kb-api sends the flag on the author's read only, so another reader sees
|
|
13
|
+
* the plain placeholder.
|
|
14
|
+
*/
|
|
15
|
+
import { computed } from 'vue'
|
|
16
|
+
import { hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
|
|
17
|
+
import { reportLink } from '../core/attrs'
|
|
18
|
+
import { formatDate } from '../core/format'
|
|
19
|
+
import AuthorAvatar from './AuthorAvatar.vue'
|
|
20
|
+
import LinkedText from './LinkedText.vue'
|
|
21
|
+
import MessageComposer from './MessageComposer.vue'
|
|
22
|
+
import MessageMenu from './MessageMenu.vue'
|
|
23
|
+
import ReactionBar from './ReactionBar.vue'
|
|
24
|
+
import { useText } from './context'
|
|
25
|
+
import { useDiscussion } from './discussion-context'
|
|
26
|
+
import type { MenuItem } from './types'
|
|
27
|
+
|
|
28
|
+
defineOptions({ name: 'MessageItem' })
|
|
29
|
+
|
|
30
|
+
const props = defineProps<{ message: DiscussionMessage }>()
|
|
31
|
+
const { t, lang } = useText()
|
|
32
|
+
const { ctl, routes, opened, originals, allReplies, deleteMessage } = useDiscussion()
|
|
33
|
+
|
|
34
|
+
/** Replies shown before „Zobrazit další odpovědi". */
|
|
35
|
+
const REPLIES_SHOWN = 3
|
|
36
|
+
|
|
37
|
+
const m = computed(() => props.message)
|
|
38
|
+
const isReply = computed(() => m.value.replyToSeq !== null)
|
|
39
|
+
const own = computed(() => ctl.isOwn(m.value))
|
|
40
|
+
const placeholder = computed(() => m.value.body === null)
|
|
41
|
+
/** The author's own message held back from others until their art. 9 consent (P-RV3) — not one awaiting review. */
|
|
42
|
+
const untilConsent = computed(() => own.value && m.value.state === 'held' && m.value.hiddenUntilConsent === true)
|
|
43
|
+
/** The consent step can be opened from here: the discussion asks this member for it, and the onboarding is not open yet. */
|
|
44
|
+
const canGiveConsent = computed(
|
|
45
|
+
() => untilConsent.value && ctl.mode() === 'onboarding' && ctl.needs().art9 && !ctl.onboarding.value && ctl.state.refusedAge === null,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const authorName = computed(() => {
|
|
49
|
+
const a = m.value.author
|
|
50
|
+
if (a.kind === 'system') return t('author.system')
|
|
51
|
+
if (a.kind === 'staff') return a.displayName
|
|
52
|
+
return a.displayName ?? t('author.deleted')
|
|
53
|
+
})
|
|
54
|
+
const handle = computed(() => (m.value.author.kind === 'member' ? m.value.author.handle : null))
|
|
55
|
+
const badge = computed(() => {
|
|
56
|
+
const a = m.value.author
|
|
57
|
+
if (a.kind !== 'staff') return null
|
|
58
|
+
const key = `author.badge.${a.badge}`
|
|
59
|
+
return hasCommunityMessage(key) ? t(key) : null
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const placeholderKey = computed<CommunityMessageKey>(() =>
|
|
63
|
+
m.value.state === 'held' ? 'discussion.placeholderHeld' : m.value.state === 'removed' ? 'discussion.placeholderRemoved' : 'discussion.placeholderDeleted',
|
|
64
|
+
)
|
|
65
|
+
const hiddenByBlock = computed(() => m.value.collapsed && !opened.has(m.value.seq))
|
|
66
|
+
const showOriginal = computed(() => originals.has(m.value.seq))
|
|
67
|
+
const text = computed(() => (m.value.isTranslated && !showOriginal.value ? m.value.displayBody : m.value.body) ?? m.value.displayBody)
|
|
68
|
+
|
|
69
|
+
const notes = computed(() =>
|
|
70
|
+
(own.value ? (m.value.notes ?? []) : []).map((code) => noteMessageKey(code)).filter((k): k is CommunityMessageKey => k !== null),
|
|
71
|
+
)
|
|
72
|
+
const warnings = computed(() => (own.value ? [...new Set((m.value.warnings ?? []).map((code) => warningMessageKey(code)))] : []))
|
|
73
|
+
|
|
74
|
+
const editing = computed(() => ctl.state.inline?.kind === 'edit' && ctl.state.inline.seq === m.value.seq)
|
|
75
|
+
const replying = computed(() => !isReply.value && ctl.state.inline?.kind === 'reply' && ctl.state.inline.seq === m.value.seq)
|
|
76
|
+
|
|
77
|
+
const shownReplies = computed(() => (allReplies.has(m.value.seq) ? m.value.replies : m.value.replies.slice(0, REPLIES_SHOWN)))
|
|
78
|
+
const moreReplies = computed(() => m.value.replies.length - shownReplies.value.length)
|
|
79
|
+
|
|
80
|
+
const target = computed(() => ({ conversationId: m.value.conversationId, seq: m.value.seq }))
|
|
81
|
+
|
|
82
|
+
const menu = computed<MenuItem[]>(() => {
|
|
83
|
+
const items: MenuItem[] = []
|
|
84
|
+
const msg = m.value
|
|
85
|
+
if (ctl.canEdit(msg)) items.push({ id: 'edit', label: t('discussion.edit') })
|
|
86
|
+
if (ctl.canDelete(msg)) items.push({ id: 'delete', label: t('discussion.delete'), danger: true })
|
|
87
|
+
if (ctl.canReport(msg)) items.push({ id: 'report', label: t('discussion.report') })
|
|
88
|
+
else if (ctl.canReportAsNotifier(msg)) {
|
|
89
|
+
const href = reportLink(routes.value.reportPath, target.value)
|
|
90
|
+
if (href) items.push({ id: 'report', label: t('discussion.report'), href })
|
|
91
|
+
}
|
|
92
|
+
if (ctl.canBlock(msg)) items.push({ id: msg.collapsed ? 'unblock' : 'block', label: msg.collapsed ? t('discussion.unblock') : t('discussion.block') })
|
|
93
|
+
return items
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
function onMenu(id: string): void {
|
|
97
|
+
const msg = m.value
|
|
98
|
+
if (id === 'edit') ctl.openEdit(msg.seq)
|
|
99
|
+
else if (id === 'delete') deleteMessage(msg.seq)
|
|
100
|
+
else if (id === 'report') ctl.openReport(msg)
|
|
101
|
+
else if ((id === 'block' || id === 'unblock') && msg.author.kind === 'member' && msg.author.memberId) void ctl.setBlocked(msg.author.memberId, id === 'block')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function reply(): void {
|
|
105
|
+
ctl.openReply(m.value.seq, isReply.value ? authorName.value : null)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toggle(set: Set<number>): void {
|
|
109
|
+
if (set.has(m.value.seq)) set.delete(m.value.seq)
|
|
110
|
+
else set.add(m.value.seq)
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<li class="bcm-msg-item">
|
|
116
|
+
<article class="bcm-msg" :class="{ 'bcm-msg-pending': !!m.pending }" :aria-busy="!!m.pending">
|
|
117
|
+
<AuthorAvatar :author="m.author" />
|
|
118
|
+
<div class="bcm-msg-main">
|
|
119
|
+
<header class="bcm-meta">
|
|
120
|
+
<span class="bcm-author">{{ authorName }}</span>
|
|
121
|
+
<span v-if="badge" class="bcm-badge bcm-badge-team">{{ badge }}</span>
|
|
122
|
+
<span v-if="handle" class="bcm-handle">{{ handle }}</span>
|
|
123
|
+
<time v-if="m.createdAt" class="bcm-time" :datetime="m.createdAt">{{ formatDate(lang, m.createdAt) }}</time>
|
|
124
|
+
<span v-if="m.isEdited && !placeholder" class="bcm-muted">{{ t('discussion.edited') }}</span>
|
|
125
|
+
<span v-if="m.pending" class="bcm-muted" role="status">{{ t('discussion.pending') }}</span>
|
|
126
|
+
</header>
|
|
127
|
+
|
|
128
|
+
<p v-if="placeholder" class="bcm-placeholder">{{ t(placeholderKey) }}</p>
|
|
129
|
+
<p v-else-if="hiddenByBlock" class="bcm-muted">
|
|
130
|
+
{{ t('discussion.collapsed') }}
|
|
131
|
+
<button type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.show') }}</button>
|
|
132
|
+
</p>
|
|
133
|
+
<template v-else>
|
|
134
|
+
<div v-if="untilConsent" class="bcm-own-state bcm-own-consent">
|
|
135
|
+
<span class="bcm-badge">{{ t('state.hiddenUntilConsent') }}</span>
|
|
136
|
+
<p class="bcm-hint">{{ t('discussion.hiddenUntilConsentNote') }}</p>
|
|
137
|
+
<button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding()">{{ t('discussion.giveConsent') }}</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div v-else-if="own && (m.state === 'held' || m.state === 'removed')" class="bcm-own-state">
|
|
140
|
+
<span class="bcm-badge">{{ m.state === 'held' ? t('state.held') : t('state.removed') }}</span>
|
|
141
|
+
<p class="bcm-hint">{{ m.state === 'held' ? t('discussion.heldNote') : t('discussion.removedNote') }}</p>
|
|
142
|
+
<a v-if="m.state === 'removed'" :href="routes.myCommunityPath">{{ t('discussion.why') }}</a>
|
|
143
|
+
</div>
|
|
144
|
+
<MessageComposer
|
|
145
|
+
v-if="editing && ctl.state.inline"
|
|
146
|
+
kind="edit"
|
|
147
|
+
:draft="ctl.state.inline"
|
|
148
|
+
:wait="ctl.waitLeft(ctl.state.inline)"
|
|
149
|
+
:rules-href="routes.rulesPath"
|
|
150
|
+
@submit="ctl.submitInline()"
|
|
151
|
+
@cancel="ctl.closeInline()"
|
|
152
|
+
@confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
|
|
153
|
+
@confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
|
|
154
|
+
/>
|
|
155
|
+
<p v-else class="bcm-text"><LinkedText :text="text" /></p>
|
|
156
|
+
<p v-if="m.isTranslated && !editing" class="bcm-hint">
|
|
157
|
+
{{ t('discussion.translated') }}
|
|
158
|
+
<button type="button" class="bcm-link" @click="toggle(originals)">{{ showOriginal ? t('discussion.showTranslation') : t('discussion.showOriginal') }}</button>
|
|
159
|
+
</p>
|
|
160
|
+
<button v-if="m.collapsed" type="button" class="bcm-link" @click="toggle(opened)">{{ t('discussion.hide') }}</button>
|
|
161
|
+
<p v-for="key in notes" :key="key" class="bcm-note">
|
|
162
|
+
<!-- The adverse-event note leads to the site's consultation page when the site names one (§7.6). -->
|
|
163
|
+
<a v-if="key === 'note.report_to_manufacturer' && routes.consultationPath" :href="routes.consultationPath">{{ t(key) }}</a>
|
|
164
|
+
<template v-else>{{ t(key) }}</template>
|
|
165
|
+
</p>
|
|
166
|
+
<p v-for="key in warnings" :key="key" class="bcm-warning">{{ t(key) }}</p>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<div v-if="!placeholder && !hiddenByBlock && !editing" class="bcm-actions">
|
|
170
|
+
<ReactionBar
|
|
171
|
+
:reaction-set="ctl.state.thread.reactionSet"
|
|
172
|
+
:summary="m.reactions"
|
|
173
|
+
:interactive="ctl.canReact() && !m.pending && m.seq > 0 && m.state === 'visible'"
|
|
174
|
+
:busy="!!ctl.state.busy[`react:${m.seq}`]"
|
|
175
|
+
@react="(emoji: string) => ctl.react(target, emoji)"
|
|
176
|
+
/>
|
|
177
|
+
<button v-if="ctl.canReply(m)" type="button" class="bcm-quiet" @click="reply">{{ t('discussion.reply') }}</button>
|
|
178
|
+
<MessageMenu :items="menu" @select="onMenu" />
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<template v-if="!isReply">
|
|
182
|
+
<ol v-if="m.replies.length" class="bcm-replies">
|
|
183
|
+
<MessageItem v-for="r in shownReplies" :key="r.id" :message="r" />
|
|
184
|
+
</ol>
|
|
185
|
+
<button v-if="moreReplies > 0" type="button" class="bcm-link" @click="allReplies.add(m.seq)">
|
|
186
|
+
{{ t('discussion.showReplies', { count: moreReplies }) }}
|
|
187
|
+
</button>
|
|
188
|
+
<p v-if="m.repliesTruncated" class="bcm-hint">{{ t('discussion.repliesTruncated') }}</p>
|
|
189
|
+
<MessageComposer
|
|
190
|
+
v-if="replying && ctl.state.inline"
|
|
191
|
+
kind="reply"
|
|
192
|
+
:draft="ctl.state.inline"
|
|
193
|
+
:wait="ctl.waitLeft(ctl.state.inline)"
|
|
194
|
+
:rules-href="routes.rulesPath"
|
|
195
|
+
:replying-to="ctl.state.inline.replyingTo"
|
|
196
|
+
@submit="ctl.submitInline()"
|
|
197
|
+
@cancel="ctl.closeInline()"
|
|
198
|
+
@confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
|
|
199
|
+
@confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
|
|
200
|
+
/>
|
|
201
|
+
</template>
|
|
202
|
+
</div>
|
|
203
|
+
</article>
|
|
204
|
+
</li>
|
|
205
|
+
</template>
|