dsh-conversation-recycle-bin 0.2.2 → 0.3.1
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/CHANGELOG.md +36 -0
- package/README.md +89 -2
- package/client.js +444 -32
- package/config.schema.json +13 -0
- package/cordis.patch.yml +20 -0
- package/lib/core/config.js +40 -0
- package/lib/core/errors.js +6 -0
- package/lib/host/api.js +24 -3
- package/lib/host/feedback.js +434 -0
- package/lib/version.js +1 -1
- package/package.json +6 -3
package/client.js
CHANGED
|
@@ -106,7 +106,7 @@ window.__ModuleLoader__.load({
|
|
|
106
106
|
'dialog.partial': 'Deleted {done}, failed {failed}.',
|
|
107
107
|
'dialog.noSession': 'The conversation no longer exists.',
|
|
108
108
|
'dialog.blocked': 'This conversation cannot be deleted: {message}',
|
|
109
|
-
'dialog.reloadHint': 'The conversation list refreshes
|
|
109
|
+
'dialog.reloadHint': 'The conversation list refreshes when you close this dialog.',
|
|
110
110
|
'trash.title': 'Recycle bin',
|
|
111
111
|
'trash.empty': 'The recycle bin is empty.',
|
|
112
112
|
'trash.deletedAt': 'Deleted {time}',
|
|
@@ -145,8 +145,45 @@ window.__ModuleLoader__.load({
|
|
|
145
145
|
'state.limits': 'Limits',
|
|
146
146
|
'state.available': 'available',
|
|
147
147
|
'state.unavailable': 'unavailable',
|
|
148
|
+
'state.notDetected': 'not detected',
|
|
148
149
|
'state.rate': '{used}/{limit} deletions this minute',
|
|
149
150
|
'state.batch': 'Effective batch limit: {limit}',
|
|
151
|
+
'feedback.button': 'Feedback',
|
|
152
|
+
'feedback.bannerTitle': 'Ran into a problem or have an idea?',
|
|
153
|
+
'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
|
|
154
|
+
'feedback.title': 'Send feedback',
|
|
155
|
+
'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
|
|
156
|
+
'feedback.categoryLabel': 'Type',
|
|
157
|
+
'feedback.categoryBug': 'Problem / bug',
|
|
158
|
+
'feedback.categorySuggestion': 'Suggestion',
|
|
159
|
+
'feedback.categoryOther': 'Other',
|
|
160
|
+
'feedback.titleLabel': 'Title',
|
|
161
|
+
'feedback.titlePlaceholder': 'One line that summarises it',
|
|
162
|
+
'feedback.bodyLabel': 'Message',
|
|
163
|
+
'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
|
|
164
|
+
'feedback.emailLabel': 'Your email (optional)',
|
|
165
|
+
'feedback.emailPlaceholder': 'you@example.com',
|
|
166
|
+
'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
|
|
167
|
+
'feedback.emailHintAsFrom': 'Shown as the sender (From) of the mail.',
|
|
168
|
+
'feedback.contextLabel': 'Attached automatically',
|
|
169
|
+
'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
|
|
170
|
+
'feedback.contextVersion': 'Plugin version',
|
|
171
|
+
'feedback.contextPlatform': 'Platform',
|
|
172
|
+
'feedback.contextLocale': 'Language',
|
|
173
|
+
'feedback.send': 'Send',
|
|
174
|
+
'feedback.sending': 'Sending…',
|
|
175
|
+
'feedback.sent': 'Feedback sent. Thank you!',
|
|
176
|
+
'feedback.tooShortTitle': 'The title needs at least {min} characters.',
|
|
177
|
+
'feedback.tooLongTitle': 'The title may be at most {max} characters.',
|
|
178
|
+
'feedback.tooShortBody': 'The message needs at least {min} characters.',
|
|
179
|
+
'feedback.tooLongBody': 'The message may be at most {max} characters.',
|
|
180
|
+
'feedback.invalidEmail': 'That does not look like an email address.',
|
|
181
|
+
'feedback.tooLongEmail': 'The email address may be at most {max} characters.',
|
|
182
|
+
'feedback.unavailable': 'The feedback channel is not configured on this Host.',
|
|
183
|
+
'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
|
|
184
|
+
'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
|
|
185
|
+
'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
|
|
186
|
+
'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
|
|
150
187
|
'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
|
|
151
188
|
'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
|
|
152
189
|
'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
|
|
@@ -222,7 +259,7 @@ window.__ModuleLoader__.load({
|
|
|
222
259
|
'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
|
|
223
260
|
'dialog.noSession': '该对话已不存在。',
|
|
224
261
|
'dialog.blocked': '该对话无法删除:{message}',
|
|
225
|
-
'dialog.reloadHint': '
|
|
262
|
+
'dialog.reloadHint': '关闭本对话框后会刷新会话列表。',
|
|
226
263
|
'trash.title': '回收站',
|
|
227
264
|
'trash.empty': '回收站为空。',
|
|
228
265
|
'trash.deletedAt': '删除于 {time}',
|
|
@@ -261,8 +298,45 @@ window.__ModuleLoader__.load({
|
|
|
261
298
|
'state.limits': '限制',
|
|
262
299
|
'state.available': '可用',
|
|
263
300
|
'state.unavailable': '不可用',
|
|
301
|
+
'state.notDetected': '未检测到',
|
|
264
302
|
'state.rate': '本分钟已删除 {used}/{limit}',
|
|
265
303
|
'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
|
|
304
|
+
'feedback.button': '反馈',
|
|
305
|
+
'feedback.bannerTitle': '遇到问题,或有改进建议?',
|
|
306
|
+
'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
|
|
307
|
+
'feedback.title': '发送反馈',
|
|
308
|
+
'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
|
|
309
|
+
'feedback.categoryLabel': '类型',
|
|
310
|
+
'feedback.categoryBug': '问题 / Bug',
|
|
311
|
+
'feedback.categorySuggestion': '建议',
|
|
312
|
+
'feedback.categoryOther': '其他',
|
|
313
|
+
'feedback.titleLabel': '标题',
|
|
314
|
+
'feedback.titlePlaceholder': '用一句话概括',
|
|
315
|
+
'feedback.bodyLabel': '正文',
|
|
316
|
+
'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
|
|
317
|
+
'feedback.emailLabel': '你的邮箱(选填)',
|
|
318
|
+
'feedback.emailPlaceholder': 'you@example.com',
|
|
319
|
+
'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
|
|
320
|
+
'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人。',
|
|
321
|
+
'feedback.contextLabel': '自动附带',
|
|
322
|
+
'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
|
|
323
|
+
'feedback.contextVersion': '插件版本',
|
|
324
|
+
'feedback.contextPlatform': '平台',
|
|
325
|
+
'feedback.contextLocale': '语言',
|
|
326
|
+
'feedback.send': '发送',
|
|
327
|
+
'feedback.sending': '正在发送…',
|
|
328
|
+
'feedback.sent': '反馈已发送,谢谢!',
|
|
329
|
+
'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
|
|
330
|
+
'feedback.tooLongTitle': '标题最多 {max} 个字符。',
|
|
331
|
+
'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
|
|
332
|
+
'feedback.tooLongBody': '正文最多 {max} 个字符。',
|
|
333
|
+
'feedback.invalidEmail': '这看起来不是一个邮箱地址。',
|
|
334
|
+
'feedback.tooLongEmail': '邮箱最多 {max} 个字符。',
|
|
335
|
+
'feedback.unavailable': '当前宿主未配置反馈通道。',
|
|
336
|
+
'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
|
|
337
|
+
'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
|
|
338
|
+
'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
|
|
339
|
+
'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
|
|
266
340
|
'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
|
|
267
341
|
'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
|
|
268
342
|
'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
|
|
@@ -484,11 +558,14 @@ window.__ModuleLoader__.load({
|
|
|
484
558
|
|
|
485
559
|
const flashStore = {
|
|
486
560
|
message: null,
|
|
561
|
+
timer: null,
|
|
487
562
|
listeners: new Set(),
|
|
488
563
|
show(message) {
|
|
564
|
+
if (this.timer !== null) clearTimeout(this.timer)
|
|
489
565
|
this.message = message
|
|
490
566
|
for (const listener of this.listeners) listener(this.message)
|
|
491
|
-
setTimeout(() => {
|
|
567
|
+
this.timer = setTimeout(() => {
|
|
568
|
+
this.timer = null
|
|
492
569
|
if (this.message === message) {
|
|
493
570
|
this.message = null
|
|
494
571
|
for (const listener of this.listeners) listener(null)
|
|
@@ -590,7 +667,35 @@ window.__ModuleLoader__.load({
|
|
|
590
667
|
|
|
591
668
|
function reloadApp(delay = 900) {
|
|
592
669
|
if (typeof window === 'undefined' || typeof window.location?.reload !== 'function') return
|
|
593
|
-
window.setTimeout(() =>
|
|
670
|
+
reloadTimer = window.setTimeout(() => {
|
|
671
|
+
reloadTimer = null
|
|
672
|
+
reloadPending = false
|
|
673
|
+
window.location.reload()
|
|
674
|
+
}, delay)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// A fallback reload is armed when the Host cannot close the deleted
|
|
678
|
+
// conversation in place. The result dialog is the only place a partial
|
|
679
|
+
// batch reports its per-session failures, so the reload waits for the user
|
|
680
|
+
// to dismiss it; the timer is only a safety net for a dialog left open.
|
|
681
|
+
const RELOAD_FALLBACK_DELAY = 5_000
|
|
682
|
+
let reloadPending = false
|
|
683
|
+
let reloadTimer = null
|
|
684
|
+
// Set when the user dismisses the result dialog. `refreshAfterMutation`
|
|
685
|
+
// settles after the dialog is already on screen, so the dismissal can
|
|
686
|
+
// arrive first; the reload then happens at once instead of arming a timer.
|
|
687
|
+
let reloadDialogDismissed = false
|
|
688
|
+
|
|
689
|
+
function takeReloadPending() {
|
|
690
|
+
const pending = reloadPending
|
|
691
|
+
reloadPending = false
|
|
692
|
+
// Cancel the safety-net timer, otherwise dismissing the dialog would
|
|
693
|
+
// reload once now and once again when the original timer fires.
|
|
694
|
+
if (pending && reloadTimer !== null) {
|
|
695
|
+
if (typeof window !== 'undefined') window.clearTimeout?.(reloadTimer)
|
|
696
|
+
reloadTimer = null
|
|
697
|
+
}
|
|
698
|
+
return pending
|
|
594
699
|
}
|
|
595
700
|
|
|
596
701
|
function service(name) {
|
|
@@ -627,7 +732,7 @@ window.__ModuleLoader__.load({
|
|
|
627
732
|
async function refreshAfterMutation(options = {}) {
|
|
628
733
|
const sessionIds = Array.isArray(options.sessionIds) ? options.sessionIds : []
|
|
629
734
|
const closeCurrent = options.closeCurrent !== false
|
|
630
|
-
const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay :
|
|
735
|
+
const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : RELOAD_FALLBACK_DELAY
|
|
631
736
|
const current = openSessionId()
|
|
632
737
|
const sessions = service('sessions')
|
|
633
738
|
let refreshed = false
|
|
@@ -665,7 +770,13 @@ window.__ModuleLoader__.load({
|
|
|
665
770
|
}
|
|
666
771
|
}
|
|
667
772
|
}
|
|
668
|
-
if (!refreshed)
|
|
773
|
+
if (!refreshed) {
|
|
774
|
+
if (reloadDialogDismissed) reloadApp(0)
|
|
775
|
+
else {
|
|
776
|
+
reloadPending = true
|
|
777
|
+
reloadApp(fallbackDelay)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
669
780
|
if (options.notify !== false) mutationStore.bump()
|
|
670
781
|
return refreshed
|
|
671
782
|
}
|
|
@@ -696,6 +807,16 @@ window.__ModuleLoader__.load({
|
|
|
696
807
|
h('path', { d: 'M20 4v5h-5' }))
|
|
697
808
|
}
|
|
698
809
|
|
|
810
|
+
function GlyphFeedback({ size = 14 }) {
|
|
811
|
+
return h('svg', {
|
|
812
|
+
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
|
813
|
+
stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
814
|
+
'aria-hidden': 'true',
|
|
815
|
+
},
|
|
816
|
+
h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
|
|
817
|
+
h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
|
|
818
|
+
}
|
|
819
|
+
|
|
699
820
|
function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
|
|
700
821
|
return h('button', {
|
|
701
822
|
type,
|
|
@@ -746,6 +867,55 @@ window.__ModuleLoader__.load({
|
|
|
746
867
|
}
|
|
747
868
|
}
|
|
748
869
|
|
|
870
|
+
/* ------------------------------------------------------------------ *
|
|
871
|
+
* Feedback form (pure helpers, shared with the Host's validation)
|
|
872
|
+
* ------------------------------------------------------------------ */
|
|
873
|
+
|
|
874
|
+
// Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
|
|
875
|
+
// every field; this copy only exists to disable the submit button early and
|
|
876
|
+
// to point at the offending input.
|
|
877
|
+
const FEEDBACK_LIMITS = Object.freeze({
|
|
878
|
+
titleMin: 4,
|
|
879
|
+
titleMax: 120,
|
|
880
|
+
bodyMin: 10,
|
|
881
|
+
bodyMax: 4_000,
|
|
882
|
+
emailMax: 200,
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
/** Same loose shape the Host accepts: `a@b.cc`, no whitespace or separators. */
|
|
886
|
+
const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* @param {{title?: string, body?: string, email?: string}} input
|
|
890
|
+
* @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
|
|
891
|
+
*/
|
|
892
|
+
function validateFeedbackInput(input) {
|
|
893
|
+
const title = String(input?.title ?? '').trim()
|
|
894
|
+
const body = String(input?.body ?? '').trim()
|
|
895
|
+
const email = String(input?.email ?? '').trim()
|
|
896
|
+
if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
|
|
897
|
+
if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
|
|
898
|
+
if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
|
|
899
|
+
if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
|
|
900
|
+
// Same order as the Host (`lib/host/feedback.js:241-250`): shape first,
|
|
901
|
+
// then length. The reverse told a 300-character address it was "not an
|
|
902
|
+
// email" while the Host said "too long" for the identical submission.
|
|
903
|
+
if (email !== '' && !EMAIL_PATTERN.test(email)) return { ok: false, key: 'feedback.invalidEmail', vars: {} }
|
|
904
|
+
if (email.length > FEEDBACK_LIMITS.emailMax) return { ok: false, key: 'feedback.tooLongEmail', vars: { max: FEEDBACK_LIMITS.emailMax } }
|
|
905
|
+
return { ok: true }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Context the dialog shows before sending; nothing here is user content. */
|
|
909
|
+
function collectFeedbackContext() {
|
|
910
|
+
const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
911
|
+
? navigator.userAgent
|
|
912
|
+
: ''
|
|
913
|
+
return {
|
|
914
|
+
platform: userAgent.slice(0, 160),
|
|
915
|
+
locale: currentLocaleId(),
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
749
919
|
/* ------------------------------------------------------------------ *
|
|
750
920
|
* Confirmation dialog
|
|
751
921
|
* ------------------------------------------------------------------ */
|
|
@@ -766,6 +936,15 @@ window.__ModuleLoader__.load({
|
|
|
766
936
|
const [error, setError] = useState(null)
|
|
767
937
|
const [phrase, setPhrase] = useState('')
|
|
768
938
|
const [done, setDone] = useState(null)
|
|
939
|
+
|
|
940
|
+
// Dismissing the result must also complete a reload the Host armed
|
|
941
|
+
// because it cannot close the deleted conversation in place; otherwise
|
|
942
|
+
// the safety-net timer would fire later and reload under the user's feet.
|
|
943
|
+
function closeDone() {
|
|
944
|
+
reloadDialogDismissed = true
|
|
945
|
+
dialogStore.close()
|
|
946
|
+
if (takeReloadPending()) reloadApp(0)
|
|
947
|
+
}
|
|
769
948
|
const requestRef = useRef(0)
|
|
770
949
|
|
|
771
950
|
const load = useCallback(async (nextMode, nextMemory, options = {}) => {
|
|
@@ -797,7 +976,9 @@ window.__ModuleLoader__.load({
|
|
|
797
976
|
|
|
798
977
|
useEffect(() => {
|
|
799
978
|
function onKey(event) {
|
|
800
|
-
|
|
979
|
+
// Escape dismisses the result dialog too, so it must release the
|
|
980
|
+
// armed reload exactly like the Close button does.
|
|
981
|
+
if (event.key === 'Escape' && busy !== true) closeDone()
|
|
801
982
|
}
|
|
802
983
|
window.addEventListener('keydown', onKey)
|
|
803
984
|
return () => window.removeEventListener('keydown', onKey)
|
|
@@ -823,6 +1004,9 @@ window.__ModuleLoader__.load({
|
|
|
823
1004
|
|
|
824
1005
|
async function submit() {
|
|
825
1006
|
if (confirmation === null || busy) return
|
|
1007
|
+
// A new submission starts a new result cycle: a dismissal from the
|
|
1008
|
+
// previous one must not force this run's reload to happen at once.
|
|
1009
|
+
reloadDialogDismissed = false
|
|
826
1010
|
setBusy(true)
|
|
827
1011
|
setError(null)
|
|
828
1012
|
try {
|
|
@@ -873,7 +1057,10 @@ window.__ModuleLoader__.load({
|
|
|
873
1057
|
}
|
|
874
1058
|
|
|
875
1059
|
if (done !== null) {
|
|
876
|
-
return h('div', {
|
|
1060
|
+
return h('div', {
|
|
1061
|
+
className: 'dc-backdrop',
|
|
1062
|
+
onMouseDown: (event) => { if (event.target === event.currentTarget) closeDone() },
|
|
1063
|
+
},
|
|
877
1064
|
h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true' },
|
|
878
1065
|
h('h2', { className: 'dc-dialog-title' }, t('dialog.done', { count: done.count, memory: done.memory })),
|
|
879
1066
|
done.failed > 0 ? h('p', { className: 'dc-error-text' }, t('dialog.partial', { done: done.count, failed: done.failed })) : null,
|
|
@@ -884,7 +1071,7 @@ window.__ModuleLoader__.load({
|
|
|
884
1071
|
: null,
|
|
885
1072
|
h('p', { className: 'dc-hint' }, t('dialog.reloadHint')),
|
|
886
1073
|
h('div', { className: 'dc-actions' },
|
|
887
|
-
h(Button, { variant: 'primary', onClick: () =>
|
|
1074
|
+
h(Button, { variant: 'primary', onClick: () => closeDone() }, t('action.close')))))
|
|
888
1075
|
}
|
|
889
1076
|
|
|
890
1077
|
return h('div', {
|
|
@@ -991,7 +1178,172 @@ window.__ModuleLoader__.load({
|
|
|
991
1178
|
if (first !== undefined) return adapterReason(first.reason)
|
|
992
1179
|
const stores = Array.isArray(memory?.stores) ? memory.stores : []
|
|
993
1180
|
const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
|
|
994
|
-
|
|
1181
|
+
// `not detected` used to be hard-coded English, so a Chinese page showed
|
|
1182
|
+
// `记忆存储不可用:not detected`. Everything else here already routes
|
|
1183
|
+
// through the string table.
|
|
1184
|
+
return store === undefined ? t('state.notDetected') : adapterReason(store.reason)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/* ------------------------------------------------------------------ *
|
|
1188
|
+
* Feedback dialog
|
|
1189
|
+
* ------------------------------------------------------------------ */
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Settings page "反馈" button.
|
|
1193
|
+
*
|
|
1194
|
+
* The Host performs the delivery, so the page never holds a mail
|
|
1195
|
+
* credential. The auto-attached context is rendered BEFORE the user
|
|
1196
|
+
* commits, so nothing leaves the machine that they did not see.
|
|
1197
|
+
*/
|
|
1198
|
+
function FeedbackDialog({ payload }) {
|
|
1199
|
+
useLocaleTick()
|
|
1200
|
+
const feedback = payload?.feedback ?? null
|
|
1201
|
+
const version = typeof payload?.version === 'string' ? payload.version : ''
|
|
1202
|
+
const [category, setCategory] = useState('bug')
|
|
1203
|
+
const [title, setTitle] = useState('')
|
|
1204
|
+
const [body, setBody] = useState('')
|
|
1205
|
+
const [email, setEmail] = useState('')
|
|
1206
|
+
const [busy, setBusy] = useState(false)
|
|
1207
|
+
const [error, setError] = useState(null)
|
|
1208
|
+
const validation = validateFeedbackInput({ title, body, email })
|
|
1209
|
+
const context = collectFeedbackContext()
|
|
1210
|
+
// A Host without a mail transport advertises `enabled: false`; the form is
|
|
1211
|
+
// still rendered read-only so the explanation is visible instead of a
|
|
1212
|
+
// button that fails on click.
|
|
1213
|
+
const unavailable = feedback?.enabled !== true
|
|
1214
|
+
// Nothing has been typed on the first render, so the "too short" rule must
|
|
1215
|
+
// not greet the user. It starts applying once the user edits a field OR
|
|
1216
|
+
// leaves one: gating on `dirty` alone left the disabled submit button with
|
|
1217
|
+
// no visible reason for anyone who only tabbed through the form.
|
|
1218
|
+
const [dirty, setDirty] = useState(false)
|
|
1219
|
+
const [touched, setTouched] = useState(false)
|
|
1220
|
+
const validationVisible = (dirty === true || touched === true) && validation.ok !== true
|
|
1221
|
+
|
|
1222
|
+
useEffect(() => {
|
|
1223
|
+
function onKey(event) {
|
|
1224
|
+
if (event.key === 'Escape' && busy !== true) dialogStore.close()
|
|
1225
|
+
}
|
|
1226
|
+
window.addEventListener('keydown', onKey)
|
|
1227
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1228
|
+
}, [busy])
|
|
1229
|
+
|
|
1230
|
+
async function submit() {
|
|
1231
|
+
if (busy || unavailable || validation.ok !== true) return
|
|
1232
|
+
setBusy(true)
|
|
1233
|
+
setError(null)
|
|
1234
|
+
try {
|
|
1235
|
+
await api('/feedback', {
|
|
1236
|
+
method: 'POST',
|
|
1237
|
+
timeoutMs: 30_000,
|
|
1238
|
+
body: {
|
|
1239
|
+
category,
|
|
1240
|
+
title: title.trim(),
|
|
1241
|
+
body: body.trim(),
|
|
1242
|
+
email: email.trim(),
|
|
1243
|
+
context: {
|
|
1244
|
+
pluginVersion: version,
|
|
1245
|
+
platform: context.platform,
|
|
1246
|
+
locale: context.locale,
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
})
|
|
1250
|
+
flashStore.show(t('feedback.sent'))
|
|
1251
|
+
dialogStore.close()
|
|
1252
|
+
} catch (caught) {
|
|
1253
|
+
// The Host log already records the stable code; this keeps the form
|
|
1254
|
+
// path debuggable from the browser console without a network tab.
|
|
1255
|
+
console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
|
|
1256
|
+
setError(caught)
|
|
1257
|
+
} finally {
|
|
1258
|
+
setBusy(false)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return h('div', {
|
|
1263
|
+
className: 'dc-backdrop',
|
|
1264
|
+
onMouseDown: (event) => {
|
|
1265
|
+
if (event.target === event.currentTarget && busy !== true) dialogStore.close()
|
|
1266
|
+
},
|
|
1267
|
+
},
|
|
1268
|
+
h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
|
|
1269
|
+
h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
|
|
1270
|
+
h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
|
|
1271
|
+
unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
|
|
1272
|
+
h(ErrorBanner, { error }),
|
|
1273
|
+
|
|
1274
|
+
h('label', { className: 'dc-field' },
|
|
1275
|
+
h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
|
|
1276
|
+
h('select', {
|
|
1277
|
+
className: 'dc-input',
|
|
1278
|
+
value: category,
|
|
1279
|
+
disabled: busy,
|
|
1280
|
+
onBlur: () => setTouched(true),
|
|
1281
|
+
onChange: (event) => { setDirty(true); setCategory(event.target.value) },
|
|
1282
|
+
},
|
|
1283
|
+
h('option', { value: 'bug' }, t('feedback.categoryBug')),
|
|
1284
|
+
h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
|
|
1285
|
+
h('option', { value: 'other' }, t('feedback.categoryOther')))),
|
|
1286
|
+
|
|
1287
|
+
h('label', { className: 'dc-field' },
|
|
1288
|
+
h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
|
|
1289
|
+
h('input', {
|
|
1290
|
+
type: 'text',
|
|
1291
|
+
className: 'dc-input',
|
|
1292
|
+
value: title,
|
|
1293
|
+
maxLength: FEEDBACK_LIMITS.titleMax,
|
|
1294
|
+
placeholder: t('feedback.titlePlaceholder'),
|
|
1295
|
+
disabled: busy,
|
|
1296
|
+
autoFocus: true,
|
|
1297
|
+
onBlur: () => setTouched(true),
|
|
1298
|
+
onChange: (event) => { setDirty(true); setTitle(event.target.value) },
|
|
1299
|
+
})),
|
|
1300
|
+
|
|
1301
|
+
h('label', { className: 'dc-field' },
|
|
1302
|
+
h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
|
|
1303
|
+
h('textarea', {
|
|
1304
|
+
className: 'dc-input dc-textarea',
|
|
1305
|
+
value: body,
|
|
1306
|
+
rows: 6,
|
|
1307
|
+
maxLength: FEEDBACK_LIMITS.bodyMax,
|
|
1308
|
+
placeholder: t('feedback.bodyPlaceholder'),
|
|
1309
|
+
disabled: busy,
|
|
1310
|
+
onBlur: () => setTouched(true),
|
|
1311
|
+
onChange: (event) => { setDirty(true); setBody(event.target.value) },
|
|
1312
|
+
})),
|
|
1313
|
+
|
|
1314
|
+
h('label', { className: 'dc-field' },
|
|
1315
|
+
h('span', { className: 'dc-field-label' }, t('feedback.emailLabel')),
|
|
1316
|
+
h('input', {
|
|
1317
|
+
type: 'email',
|
|
1318
|
+
className: 'dc-input',
|
|
1319
|
+
value: email,
|
|
1320
|
+
maxLength: FEEDBACK_LIMITS.emailMax,
|
|
1321
|
+
placeholder: t('feedback.emailPlaceholder'),
|
|
1322
|
+
autoComplete: 'email',
|
|
1323
|
+
disabled: busy,
|
|
1324
|
+
onBlur: () => setTouched(true),
|
|
1325
|
+
onChange: (event) => { setDirty(true); setEmail(event.target.value) },
|
|
1326
|
+
}),
|
|
1327
|
+
h('div', { className: 'dc-hint' }, t(feedback?.visitorAsFrom === true ? 'feedback.emailHintAsFrom' : 'feedback.emailHint'))),
|
|
1328
|
+
|
|
1329
|
+
h('div', { className: 'dc-context' },
|
|
1330
|
+
h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
|
|
1331
|
+
h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
|
|
1332
|
+
h('ul', { className: 'dc-context-list' },
|
|
1333
|
+
h('li', null, `${t('feedback.contextVersion')}: ${version === '' ? '—' : version}`),
|
|
1334
|
+
h('li', null, `${t('feedback.contextPlatform')}: ${context.platform === '' ? '—' : context.platform}`),
|
|
1335
|
+
h('li', null, `${t('feedback.contextLocale')}: ${context.locale}`))),
|
|
1336
|
+
|
|
1337
|
+
validationVisible ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
|
|
1338
|
+
busy ? h(Spinner, { label: t('feedback.sending') }) : null,
|
|
1339
|
+
|
|
1340
|
+
h('div', { className: 'dc-actions' },
|
|
1341
|
+
h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
|
|
1342
|
+
h(Button, {
|
|
1343
|
+
variant: 'primary',
|
|
1344
|
+
onClick: () => { void submit() },
|
|
1345
|
+
disabled: busy || unavailable || validation.ok !== true,
|
|
1346
|
+
}, busy ? t('feedback.sending') : t('feedback.send')))))
|
|
995
1347
|
}
|
|
996
1348
|
|
|
997
1349
|
/* ------------------------------------------------------------------ *
|
|
@@ -1014,8 +1366,15 @@ window.__ModuleLoader__.load({
|
|
|
1014
1366
|
function DialogHost() {
|
|
1015
1367
|
const payload = useDialogState()
|
|
1016
1368
|
const flash = useFlashMessage()
|
|
1369
|
+
// `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
|
|
1370
|
+
// payload without a kind keeps its old meaning.
|
|
1371
|
+
const dialog = payload === null
|
|
1372
|
+
? null
|
|
1373
|
+
: payload.kind === 'feedback'
|
|
1374
|
+
? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
|
|
1375
|
+
: h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
|
|
1017
1376
|
const content = h(React.Fragment, null,
|
|
1018
|
-
|
|
1377
|
+
dialog,
|
|
1019
1378
|
flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
|
|
1020
1379
|
return portalIntoBody(content)
|
|
1021
1380
|
}
|
|
@@ -1082,7 +1441,7 @@ window.__ModuleLoader__.load({
|
|
|
1082
1441
|
* log) gets a Restore button: `performDiscard` refuses exactly those
|
|
1083
1442
|
* entries, so without this the only way out was a manual API call.
|
|
1084
1443
|
*/
|
|
1085
|
-
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId }) {
|
|
1444
|
+
function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
|
|
1086
1445
|
return h('div', { className: 'dc-panel dc-incomplete' },
|
|
1087
1446
|
h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
|
|
1088
1447
|
h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
|
|
@@ -1110,10 +1469,10 @@ window.__ModuleLoader__.load({
|
|
|
1110
1469
|
}),
|
|
1111
1470
|
h(Button, {
|
|
1112
1471
|
variant: 'danger',
|
|
1113
|
-
disabled: discardPhrase.trim() !== 'DELETE',
|
|
1472
|
+
disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
|
|
1114
1473
|
onClick: () => onDiscard(entry.trashId),
|
|
1115
1474
|
}, t('trash.discard')),
|
|
1116
|
-
h(Button, { onClick: onCancel }, t('action.cancel')))
|
|
1475
|
+
h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
|
|
1117
1476
|
: h(Button, {
|
|
1118
1477
|
onClick: () => onStart(entry.trashId),
|
|
1119
1478
|
title: t('trash.confirmDiscard'),
|
|
@@ -1164,8 +1523,17 @@ window.__ModuleLoader__.load({
|
|
|
1164
1523
|
const [discardTarget, setDiscardTarget] = useState(null)
|
|
1165
1524
|
const [discardPhrase, setDiscardPhrase] = useState('')
|
|
1166
1525
|
const [restoreBusyId, setRestoreBusyId] = useState(null)
|
|
1526
|
+
const [purgeBusy, setPurgeBusy] = useState(false)
|
|
1527
|
+
const [discardBusyId, setDiscardBusyId] = useState(null)
|
|
1528
|
+
|
|
1529
|
+
// Two refreshes can overlap (mount, mutation broadcast, and the refresh a
|
|
1530
|
+
// purge triggers), and the older response must not overwrite the newer.
|
|
1531
|
+
const refreshSerial = useRef(0)
|
|
1167
1532
|
|
|
1168
1533
|
const refresh = useCallback(async () => {
|
|
1534
|
+
const serial = refreshSerial.current + 1
|
|
1535
|
+
refreshSerial.current = serial
|
|
1536
|
+
const stale = () => serial !== refreshSerial.current
|
|
1169
1537
|
setLoading(true)
|
|
1170
1538
|
setError(null)
|
|
1171
1539
|
try {
|
|
@@ -1175,6 +1543,7 @@ window.__ModuleLoader__.load({
|
|
|
1175
1543
|
api('/trash'),
|
|
1176
1544
|
api('/audit?limit=50'),
|
|
1177
1545
|
])
|
|
1546
|
+
if (stale()) return
|
|
1178
1547
|
const nextSessions = Array.isArray(sessionsResponse.sessions) ? sessionsResponse.sessions : []
|
|
1179
1548
|
setState(stateResponse.state)
|
|
1180
1549
|
setSessions(nextSessions)
|
|
@@ -1188,9 +1557,9 @@ window.__ModuleLoader__.load({
|
|
|
1188
1557
|
return next.size === current.size ? current : next
|
|
1189
1558
|
})
|
|
1190
1559
|
} catch (caught) {
|
|
1191
|
-
setError(caught)
|
|
1560
|
+
if (!stale()) setError(caught)
|
|
1192
1561
|
} finally {
|
|
1193
|
-
setLoading(false)
|
|
1562
|
+
if (!stale()) setLoading(false)
|
|
1194
1563
|
}
|
|
1195
1564
|
}, [])
|
|
1196
1565
|
|
|
@@ -1247,6 +1616,8 @@ window.__ModuleLoader__.load({
|
|
|
1247
1616
|
}
|
|
1248
1617
|
|
|
1249
1618
|
async function purge(trashId, phrase) {
|
|
1619
|
+
if (purgeBusy === true) return
|
|
1620
|
+
setPurgeBusy(true)
|
|
1250
1621
|
setError(null)
|
|
1251
1622
|
const entry = trash.find((item) => item.trashId === trashId)
|
|
1252
1623
|
try {
|
|
@@ -1263,10 +1634,14 @@ window.__ModuleLoader__.load({
|
|
|
1263
1634
|
})
|
|
1264
1635
|
} catch (caught) {
|
|
1265
1636
|
setError(caught)
|
|
1637
|
+
} finally {
|
|
1638
|
+
setPurgeBusy(false)
|
|
1266
1639
|
}
|
|
1267
1640
|
}
|
|
1268
1641
|
|
|
1269
1642
|
async function discard(trashId, phrase) {
|
|
1643
|
+
if (discardBusyId !== null) return
|
|
1644
|
+
setDiscardBusyId(trashId)
|
|
1270
1645
|
setError(null)
|
|
1271
1646
|
try {
|
|
1272
1647
|
await api('/trash/discard', { method: 'POST', body: { trashId, confirmPhrase: phrase } })
|
|
@@ -1276,6 +1651,8 @@ window.__ModuleLoader__.load({
|
|
|
1276
1651
|
await refresh()
|
|
1277
1652
|
} catch (caught) {
|
|
1278
1653
|
setError(caught)
|
|
1654
|
+
} finally {
|
|
1655
|
+
setDiscardBusyId(null)
|
|
1279
1656
|
}
|
|
1280
1657
|
}
|
|
1281
1658
|
|
|
@@ -1294,13 +1671,34 @@ window.__ModuleLoader__.load({
|
|
|
1294
1671
|
: (state?.limits?.maxBatchSessions ?? 0)
|
|
1295
1672
|
const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
|
|
1296
1673
|
|
|
1674
|
+
const openFeedback = () => dialogStore.open({
|
|
1675
|
+
kind: 'feedback',
|
|
1676
|
+
key: 'feedback',
|
|
1677
|
+
version: typeof state?.version === 'string' ? state.version : '',
|
|
1678
|
+
feedback: state?.feedback ?? null,
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1297
1681
|
return h('div', { className: 'dc-root' },
|
|
1682
|
+
// Top-of-section call to action, above the title: it is the first thing
|
|
1683
|
+
// a user unsure about the plugin should see. Rendered only when the Host
|
|
1684
|
+
// reports a configured transport, so a profile without mail settings
|
|
1685
|
+
// never shows a button that cannot work (the dialog would otherwise
|
|
1686
|
+
// explain a failure to send).
|
|
1687
|
+
state?.feedback?.enabled === true
|
|
1688
|
+
? h('div', { className: 'dc-feedback-bar' },
|
|
1689
|
+
h('div', { className: 'dc-feedback-bar-text' },
|
|
1690
|
+
h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
|
|
1691
|
+
h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
|
|
1692
|
+
h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
|
|
1693
|
+
h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
|
|
1694
|
+
: null,
|
|
1298
1695
|
h('div', { className: 'dc-header' },
|
|
1299
1696
|
h('div', null,
|
|
1300
1697
|
h('h1', { className: 'dc-title' }, t('section.title')),
|
|
1301
1698
|
h('p', { className: 'dc-hint' }, t('section.subtitle'))),
|
|
1302
|
-
h(
|
|
1303
|
-
h(
|
|
1699
|
+
h('div', { className: 'dc-header-actions' },
|
|
1700
|
+
h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
|
|
1701
|
+
h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
|
|
1304
1702
|
|
|
1305
1703
|
h(ErrorBanner, { error }),
|
|
1306
1704
|
|
|
@@ -1324,13 +1722,13 @@ window.__ModuleLoader__.load({
|
|
|
1324
1722
|
h('div', { className: 'dc-hint' }, t('state.batch', { limit: effectiveBatch > 0 ? effectiveBatch : '—' })))) : null,
|
|
1325
1723
|
|
|
1326
1724
|
h('div', { className: 'dc-tabs', role: 'tablist' },
|
|
1327
|
-
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
|
|
1328
|
-
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
|
|
1329
|
-
h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
|
|
1725
|
+
h('button', { id: 'dc-tab-trash', type: 'button', role: 'tab', 'aria-selected': tab === 'trash', 'aria-controls': 'dc-panel-trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
|
|
1726
|
+
h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
|
|
1727
|
+
h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
|
|
1330
1728
|
|
|
1331
1729
|
loading ? h(Spinner, { label: t('dialog.loading') }) : null,
|
|
1332
1730
|
|
|
1333
|
-
tab === 'trash' && !loading ? h('div', { className: 'dc-panel' },
|
|
1731
|
+
tab === 'trash' && !loading ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
|
|
1334
1732
|
trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
|
|
1335
1733
|
...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
|
|
1336
1734
|
h('div', { className: 'dc-row-main' },
|
|
@@ -1347,10 +1745,10 @@ window.__ModuleLoader__.load({
|
|
|
1347
1745
|
}),
|
|
1348
1746
|
h(Button, {
|
|
1349
1747
|
variant: 'danger',
|
|
1350
|
-
disabled: purgePhrase.trim() !== 'DELETE',
|
|
1748
|
+
disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
|
|
1351
1749
|
onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
|
|
1352
1750
|
}, t('action.purge')),
|
|
1353
|
-
h(Button, { onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
|
|
1751
|
+
h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
|
|
1354
1752
|
: h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
|
|
1355
1753
|
|
|
1356
1754
|
tab === 'trash' && !loading && incomplete.length > 0 ? h(IncompleteEntries, {
|
|
@@ -1363,9 +1761,10 @@ window.__ModuleLoader__.load({
|
|
|
1363
1761
|
onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
|
|
1364
1762
|
onRestore: (trashId) => { void restoreIncomplete(trashId) },
|
|
1365
1763
|
restoreBusyId,
|
|
1764
|
+
discardBusyId,
|
|
1366
1765
|
}) : null,
|
|
1367
1766
|
|
|
1368
|
-
tab === 'batch' && !loading ? h('div', { className: 'dc-panel' },
|
|
1767
|
+
tab === 'batch' && !loading ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
|
|
1369
1768
|
h('div', { className: 'dc-batch-bar' },
|
|
1370
1769
|
h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
|
|
1371
1770
|
// "Select all" only marks deletable rows: a batch containing one
|
|
@@ -1391,7 +1790,7 @@ window.__ModuleLoader__.load({
|
|
|
1391
1790
|
onToggle: toggle,
|
|
1392
1791
|
}))) : null,
|
|
1393
1792
|
|
|
1394
|
-
tab === 'audit' && !loading ? h('div', { className: 'dc-panel' },
|
|
1793
|
+
tab === 'audit' && !loading ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
|
|
1395
1794
|
audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
|
|
1396
1795
|
...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
|
|
1397
1796
|
h('div', { className: 'dc-row-main' },
|
|
@@ -1410,10 +1809,10 @@ window.__ModuleLoader__.load({
|
|
|
1410
1809
|
* ------------------------------------------------------------------ */
|
|
1411
1810
|
|
|
1412
1811
|
const CSS = `
|
|
1413
|
-
/*
|
|
1414
|
-
|
|
1415
|
-
.dc-backdrop{position:fixed;inset:0;z-index:
|
|
1416
|
-
.dc-dialog{width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
|
|
1812
|
+
/* 1300 clears the settings modal (1000) and the host panels that also use
|
|
1813
|
+
1100, since this backlog shares document.body with them. */
|
|
1814
|
+
.dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
|
|
1815
|
+
.dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
|
|
1417
1816
|
.dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
|
|
1418
1817
|
.dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
|
|
1419
1818
|
.dc-section{margin-top:10px}
|
|
@@ -1436,6 +1835,13 @@ window.__ModuleLoader__.load({
|
|
|
1436
1835
|
.dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
|
|
1437
1836
|
.dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
|
|
1438
1837
|
.dc-input-small{width:90px;height:28px}
|
|
1838
|
+
.dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
|
|
1839
|
+
.dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
|
|
1840
|
+
.dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
|
|
1841
|
+
.dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
|
|
1842
|
+
.dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
|
|
1843
|
+
.dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
|
|
1844
|
+
.dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
|
|
1439
1845
|
.dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
|
|
1440
1846
|
.dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
|
|
1441
1847
|
.dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
|
|
@@ -1459,6 +1865,10 @@ window.__ModuleLoader__.load({
|
|
|
1459
1865
|
.dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
|
|
1460
1866
|
.dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
|
|
1461
1867
|
.dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
|
|
1868
|
+
.dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
|
|
1869
|
+
.dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
|
|
1870
|
+
.dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
|
|
1871
|
+
.dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
|
|
1462
1872
|
.dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
|
|
1463
1873
|
.dc-title{font-size:17px;font-weight:600;margin:0}
|
|
1464
1874
|
.dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
|
|
@@ -1479,7 +1889,7 @@ window.__ModuleLoader__.load({
|
|
|
1479
1889
|
.dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1480
1890
|
.dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
|
|
1481
1891
|
.dc-row-time{font-size:11px;color:var(--dsw-alias-label-secondary,#646a73);white-space:nowrap}
|
|
1482
|
-
.dc-inline-confirm{display:flex;gap:6px;align-items:center}
|
|
1892
|
+
.dc-inline-confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
1483
1893
|
.dc-batch-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
|
|
1484
1894
|
.dc-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;box-shadow:0 10px 30px rgba(0,0,0,.22);padding:9px 14px;font-size:12px;max-width:80vw}
|
|
1485
1895
|
`
|
|
@@ -1565,7 +1975,9 @@ window.__ModuleLoader__.load({
|
|
|
1565
1975
|
exports.__api = api
|
|
1566
1976
|
// Direct component handles keep the hook-level behavior testable without a
|
|
1567
1977
|
// DOM renderer (the registered wrapper alone only builds an element).
|
|
1568
|
-
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow }
|
|
1978
|
+
exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
|
|
1979
|
+
exports.__validateFeedbackInput = validateFeedbackInput
|
|
1980
|
+
exports.__feedbackLimits = FEEDBACK_LIMITS
|
|
1569
1981
|
exports.__selectableSessionIds = selectableSessionIds
|
|
1570
1982
|
exports.__adapterLabel = adapterLabel
|
|
1571
1983
|
exports.__adapterReason = adapterReason
|