domma-cms 1.6.56 → 1.6.57

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.
Files changed (111) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. package/server/server.js +1 -1
@@ -8,10 +8,11 @@
8
8
  * Right-click (or the row's kebab, or Shift+F10) for the quick moves.
9
9
  */
10
10
 
11
- import {FILES, api, esc, openPanel, refreshBadge, showFiles, stamp, store, when} from '../lib/kit.js';
11
+ import {FILES, api, copyText, esc, guardPanel, offerDraft, openPanel, panelSet, refreshBadge, rowKeys, showFiles, stamp, store, when} from '../lib/kit.js';
12
+ import {DRAFT_KEYS, draftQuestion, draftText, draftValues, reportForm, reportPrint} from '../lib/drafts.js';
12
13
 
13
14
  const ROW = `
14
- <div class="fb-row" data-id="{{id}}" tabindex="0" role="button">
15
+ <div class="fb-row" data-id="{{id}}" tabindex="0" role="button" aria-haspopup="menu">
15
16
  <span class="fb-type" data-bind-title="typeLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
16
17
  <span class="fb-main"><strong data-bind-text="title"></strong><small data-bind-text="meta"></small></span>
17
18
  <span class="fb-key" data-bind-hidden="!issueKey" data-bind-text="issueKey"></span>
@@ -32,6 +33,8 @@ export async function mount(root, ctx) {
32
33
  const statusOf = (s) => statuses.find(x => x.key === s)?.label || s;
33
34
 
34
35
  const reports = M.observable([]);
36
+ const panels = panelSet();
37
+ scope(() => panels.leave());
35
38
  const sites = M.observable({});
36
39
  const loading = M.observable(true);
37
40
  const error = M.observable('');
@@ -82,7 +85,7 @@ export async function mount(root, ctx) {
82
85
  view.innerHTML = `
83
86
  <div class="fb-bar">
84
87
  <div class="fb-seg" role="group" aria-label="Show">
85
- ${FILTERS.map(f => `<button type="button" data-filter="${esc(f.key)}" data-bind-class="filter.value === '${esc(f.key)}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value['${esc(f.key)}']"></b></button>`).join('')}
88
+ ${FILTERS.map(f => `<button type="button" data-filter="${esc(f.key)}" data-bind-class="filter.value === '${esc(f.key)}' && 'is-on'" data-bind-aria-pressed="filter.value === '${esc(f.key)}' ? 'true' : 'false'">${esc(f.label)} <b data-bind-text="counts.value['${esc(f.key)}']"></b></button>`).join('')}
86
89
  </div>
87
90
  <span data-help-title="Feedback inbox" data-help="Everything the sites' admins sent. Open a report to set its status, reply (the site sees the reply and the status, never the internal notes) or make a Waypoint issue from it - once linked, the report follows the issue: planned, in progress, done. Right-click a row for quick moves."></span>
88
91
  </div>
@@ -95,8 +98,9 @@ export async function mount(root, ctx) {
95
98
  ${types.map(t => `<option value="${esc(t.key)}">${esc(t.short)}</option>`).join('')}
96
99
  </select>
97
100
  <input type="search" class="form-input fb-search" placeholder="Search title, text, person, site..." aria-label="Search feedback" data-model="query.value">
101
+ <span data-help-title="Narrowing the list" data-help="The chips above pick a status (Open is everything not Done or Not planned); these narrow it further to one site or one kind of report. The search looks in the title, the text, who sent it, the site and the Waypoint key. The chip counts are for every site and type."></span>
98
102
  </div>
99
- <p class="fb-note" data-bind-hidden="!loading.value">Loading...</p>
103
+ <div class="fb-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
100
104
  <p class="fb-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
101
105
  <p class="fb-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
102
106
  <div class="fb-list" data-each="rows.value key=id"></div>`;
@@ -129,23 +133,29 @@ export async function mount(root, ctx) {
129
133
 
130
134
  async function load() {
131
135
  try {
132
- const res = await api.reports();
136
+ const res = await kit.whileLoading(view.querySelector('.fb-loading'), api.reports(), kit.SHAPES.iconRows);
133
137
  if (!here()) return;
134
138
  sites.value = res.sites || {};
135
139
  reports.value = Array.isArray(res.reports) ? res.reports : [];
136
140
  error.value = '';
137
141
  } catch (err) {
138
- error.value = `Could not load the feedback: ${err.message || err}`;
142
+ if (here()) error.value = `Could not load the feedback: ${err.message || err}`;
139
143
  } finally {
140
- loading.value = false;
141
- I.scan(view);
144
+ if (here()) { loading.value = false; I.scan(view); }
142
145
  }
143
146
  }
144
- const replace = (r) => { reports.value = reports.peek().map(x => (x.id === r.id ? {...x, ...r} : x)); };
147
+ /** A report changed: the list, and the open panel when it shows that report. */
148
+ const replace = (r) => {
149
+ reports.value = reports.peek().map(x => (x.id === r.id ? {...x, ...r} : x));
150
+ const showing = panels.get('detail');
151
+ if (showing?.key === r.id) showing.changed?.(r);
152
+ };
145
153
 
146
154
  async function setStatus(r, status) {
147
155
  try {
148
- replace(await api.update(r.id, {status}));
156
+ const next = await api.update(r.id, {status});
157
+ if (!here()) return;
158
+ replace(next);
149
159
  refreshBadge();
150
160
  E.toast(`${statusOf(status)}.`, {type: 'success'});
151
161
  } catch (err) {
@@ -157,6 +167,11 @@ export async function mount(root, ctx) {
157
167
  if (!await E.confirm(`Delete "${esc(r.title)}"? The site will no longer see it either. This cannot be undone.`)) return;
158
168
  try {
159
169
  await api.remove(r.id);
170
+ // Deleted on purpose: its panel goes without asking, and so does any draft on it.
171
+ const showing = panels.get('detail');
172
+ if (showing?.key === r.id) showing.close?.(true);
173
+ store.remove(DRAFT_KEYS.report(r.id));
174
+ if (!here()) return;
160
175
  reports.value = reports.peek().filter(x => x.id !== r.id);
161
176
  refreshBadge();
162
177
  E.toast('Deleted.', {type: 'success'});
@@ -165,15 +180,50 @@ export async function mount(root, ctx) {
165
180
  }
166
181
  }
167
182
 
183
+ /** One report open at a time: opening another closes (and may ask about) the one showing. */
168
184
  async function open(reportId) {
185
+ const showing = panels.get('detail');
186
+ if (showing?.key === reportId) return;
187
+ if (showing) { await showing.close?.(); if (panels.get('detail')) return; }
188
+ if (!here() || !panels.claim('detail')) return;
189
+ let opened = false;
190
+ try { opened = await openReport(reportId); } catch (err) { E.toast(err.message || String(err), {type: 'error'}); }
191
+ if (!opened) panels.release('detail');
192
+ }
193
+
194
+ async function openReport(reportId) {
195
+ const key = DRAFT_KEYS.report(reportId);
169
196
  let r;
170
- try { r = await api.report(reportId); } catch (err) { E.toast(err.message || String(err), {type: 'error'}); return; }
197
+ try { r = await api.report(reportId); } catch (err) {
198
+ if (!here()) return false;
199
+ // Gone - and with unsaved work on it: say so, and offer the words back.
200
+ if (err?.status === 404 || /not found|no such/i.test(err?.message || '')) {
201
+ const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, null, {what: 'a report'})});
202
+ if (offer.state === 'keep' && await copyText(draftText(offer.draft), 'Your unsaved reply and notes are copied')) store.remove(key);
203
+ if (offer.state === 'none') E.toast('That report is no longer here.', {type: 'warning'});
204
+ return false;
205
+ }
206
+ E.toast(err.message || String(err), {type: 'error'});
207
+ return false;
208
+ }
209
+ if (!here()) return false;
171
210
  replace(r);
211
+ // Opening a new report is seeing it - before the draft question, so the question and
212
+ // the form both see the status the site will.
213
+ if (r.status === 'new') {
214
+ try { r = await api.update(r.id, {status: 'acknowledged'}); if (here()) { replace(r); refreshBadge(); } } catch { /* stays new */ }
215
+ if (!here()) return false;
216
+ }
217
+ const offer = await offerDraft(key, {here, question: (d) => draftQuestion(d, reportPrint(r), {what: `the report “${r.title}”`, changedAt: r.updatedAt})});
218
+ if (offer.state === 'gone') return false;
172
219
  let wp = {available: false, projects: []};
173
220
  if (!r.issueId) { try { wp = await api.waypoint(); } catch { /* no Waypoint */ } }
221
+ if (!here()) return false;
174
222
 
223
+ const saved = reportForm(r);
224
+ const start = offer.state === 'keep' ? draftValues(offer.draft, saved) : saved;
175
225
  const f = {
176
- status: M.observable(r.status), reply: M.observable(r.reply || ''), notes: M.observable(r.notes || ''),
226
+ status: M.observable(start.status), reply: M.observable(start.reply), notes: M.observable(start.notes),
177
227
  busy: M.observable(false),
178
228
  project: M.observable(wp.projects?.[0]?.id || ''),
179
229
  itype: M.observable(r.type === 'bug' ? 'bug' : r.type === 'feature' ? 'story' : 'task'),
@@ -189,13 +239,14 @@ export async function mount(root, ctx) {
189
239
  ...(r.where ? [['Where', r.where]] : []),
190
240
  ...(r.cmsVersion ? [['CMS', r.cmsVersion]] : [])
191
241
  ];
192
- let panel = null;
242
+ let guard = null;
193
243
  let filesView = null;
194
- panel = await openPanel({
244
+ let base = saved;
245
+ const panel = await openPanel({
195
246
  title: r.title,
196
247
  size: 'lg',
197
248
  model: f,
198
- cleanup: () => filesView?.dispose(),
249
+ cleanup: () => { filesView?.dispose(); panels.release('detail'); },
199
250
  html: `<div class="fb-detail">
200
251
  <dl class="fb-facts">${facts.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
201
252
  <h4>What they sent</h4>
@@ -207,11 +258,12 @@ export async function mount(root, ctx) {
207
258
  <select id="fb-status" class="form-select" data-model="status.value">
208
259
  ${statuses.map(s => `<option value="${esc(s.key)}">${esc(s.label)}</option>`).join('')}
209
260
  </select>
210
- <label class="form-label" for="fb-reply">Reply to the site <span data-help="The site's admins see this in their Feedback screen, and are told there is a reply."></span></label>
261
+ <label class="form-label" for="fb-reply">Reply to the site <span data-help="The site's admins see this in their Feedback screen, and are told there is a reply. Unsaved typing is kept if you close the browser or leave this screen, and offered back next time you open the report."></span></label>
211
262
  <textarea id="fb-reply" class="form-textarea" rows="5" maxlength="8000" data-model="reply.value"></textarea>
212
263
  <label class="form-label" for="fb-notes">Internal notes <span data-help="Only here. Never sent to the site."></span></label>
213
264
  <textarea id="fb-notes" class="form-textarea" rows="3" maxlength="8000" data-model="notes.value"></textarea>
214
265
  <div class="fb-actions"><span class="fb-spacer"></span>
266
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
215
267
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="busy.value"><span data-icon="check" data-icon-size="13"></span> Save</button>
216
268
  </div>
217
269
  </div>
@@ -236,26 +288,34 @@ export async function mount(root, ctx) {
236
288
  <ul class="fb-history">${(r.history || []).slice().reverse().map(h => `<li><span>${esc(h.what)}</span><small>${esc(h.by || '')} · ${esc(stamp(h.at))}</small></li>`).join('')}</ul>
237
289
  </div>`,
238
290
  methods: {
291
+ cancel: () => guard?.close(),
239
292
  async save() {
240
293
  if (f.busy.peek()) return;
241
294
  f.busy.value = true;
295
+ // The panel closes once saved: locked meanwhile, so nothing typed during the request is lost.
296
+ guard?.lock(true);
242
297
  try {
243
298
  const next = await api.update(r.id, {status: f.status.peek(), reply: f.reply.peek(), notes: f.notes.peek()});
299
+ guard?.settle();
300
+ guard?.close(true);
301
+ if (!here()) return;
244
302
  replace(next);
245
303
  refreshBadge();
246
304
  E.toast('Saved.', {type: 'success'});
247
- panel?.close();
248
305
  } catch (err) {
306
+ guard?.lock(false);
249
307
  E.toast(err.message || String(err), {type: 'error'});
250
308
  } finally {
251
309
  f.busy.value = false;
252
310
  }
253
311
  },
254
- openIssue(e) {
312
+ async openIssue(e) {
255
313
  e?.preventDefault?.();
256
314
  const key = f.issueKey.peek();
257
315
  if (!key) return;
258
- panel?.close();
316
+ // Unsaved typing asks first; Keep editing stays here.
317
+ await guard?.close();
318
+ if (panels.get('detail')?.key === r.id) return;
259
319
  location.hash = `#/plugins/waypoint-pro/i/${encodeURIComponent(key)}`;
260
320
  },
261
321
  async promote() {
@@ -263,9 +323,8 @@ export async function mount(root, ctx) {
263
323
  f.busy.value = true;
264
324
  try {
265
325
  const next = await api.promote(r.id, {projectId: f.project.peek(), type: f.itype.peek(), priority: f.priority.peek()});
326
+ if (!here()) return;
266
327
  replace(next);
267
- f.issueKey.value = next.issueKey || '';
268
- f.status.value = next.status;
269
328
  refreshBadge();
270
329
  E.toast(`Filed as ${next.issueKey || 'a Waypoint issue'}.`, {type: 'success'});
271
330
  } catch (err) {
@@ -276,24 +335,40 @@ export async function mount(root, ctx) {
276
335
  }
277
336
  }
278
337
  });
338
+ guard = guardPanel(panel, {
339
+ key,
340
+ values: () => ({status: f.status.value, reply: f.reply.value, notes: f.notes.value}),
341
+ baseline: saved,
342
+ // A picked-up draft keeps its own `was`: the question next time still measures from when the edits began.
343
+ was: offer.state === 'keep' ? offer.draft.was : reportPrint(r)
344
+ });
345
+ panels.set('detail', {
346
+ key: r.id,
347
+ close: (how) => guard.close(how),
348
+ // The report moved elsewhere (a row-menu status, a Waypoint issue): what was not edited here follows it.
349
+ changed(next) {
350
+ const now = reportForm(next);
351
+ for (const k of Object.keys(now)) if (f[k].peek() === base[k]) f[k].value = now[k];
352
+ base = now;
353
+ f.issueKey.value = next.issueKey || '';
354
+ f.issueStatus.value = next.issueStatus || '';
355
+ guard.rebase(now, reportPrint(next));
356
+ }
357
+ });
279
358
  const host = panel.panel.querySelector('[data-files]');
280
359
  if (host) {
281
360
  host.innerHTML = FILES;
282
361
  filesView = showFiles(host, r.attachments, (a) => api.reportFileUrl(r.id, a.id), 'on its way from the site');
283
362
  }
284
- // Opening a new report is seeing it.
285
- if (r.status === 'new') {
286
- try { const next = await api.update(r.id, {status: 'acknowledged'}); replace(next); f.status.value = next.status; refreshBadge(); }
287
- catch { /* stays new */ }
288
- }
363
+ return true;
289
364
  }
290
365
 
291
366
  const bindings = M.applyBindings({loading, error, filter, site, type, query, rows, counts, emptyText}, view);
292
367
  scope(() => bindings.dispose());
293
- view.querySelector('.fb-seg').addEventListener('click', (e) => {
294
- const b = e.target.closest('[data-filter]');
295
- if (b) filter.value = b.dataset.filter;
296
- });
368
+ const seg = view.querySelector('.fb-seg');
369
+ const onFilter = (e) => { const b = e.target.closest('[data-filter]'); if (b) filter.value = b.dataset.filter; };
370
+ seg.addEventListener('click', onFilter);
371
+ scope(() => seg.removeEventListener('click', onFilter));
297
372
 
298
373
  // Rows: click or Enter opens; right-click, the kebab or Shift+F10 = the menu.
299
374
  const list = view.querySelector('.fb-list');
@@ -308,26 +383,15 @@ export async function mount(root, ctx) {
308
383
  {type: 'divider'},
309
384
  ...statuses.filter(s => s.key !== r.status).map(s => ({label: `Mark ${s.label.toLowerCase()}`, icon: 'flag', action: () => setStatus(r, s.key)})),
310
385
  {type: 'divider'},
386
+ ...(r.userEmail ? [{label: 'Copy sender\'s email', icon: 'copy', action: () => copyText(r.userEmail, 'Email copied')}] : []),
387
+ ...(r.issueKey ? [{label: `Open ${r.issueKey} in Waypoint`, icon: 'external-link', action: () => { location.hash = `#/plugins/waypoint-pro/i/${encodeURIComponent(r.issueKey)}`; }}] : []),
388
+ ...(r.userEmail || r.issueKey ? [{type: 'divider'}] : []),
311
389
  {label: 'Delete', icon: 'trash', danger: true, action: () => remove(r)}
312
390
  ];
313
391
  }
314
392
  });
315
- const onClick = (e) => {
316
- const row = e.target.closest('.fb-row');
317
- if (!row) return;
318
- const kebab = e.target.closest('.fb-kebab');
319
- if (kebab) { kit.openMenuAt(kebab); return; }
320
- open(row.dataset.id);
321
- };
322
- const onKey = (e) => {
323
- const row = e.target.closest('.fb-row');
324
- if (!row) return;
325
- if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); kit.openMenuAt(row); }
326
- else if (e.key === 'Enter' && e.target === row) open(row.dataset.id);
327
- };
328
- list.addEventListener('click', onClick);
329
- list.addEventListener('keydown', onKey);
330
- scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
393
+ scope(() => menu?.destroy?.());
394
+ scope(rowKeys(list, {rowSel: '.fb-row', kebabSel: '.fb-kebab', openMenuAt: kit.openMenuAt, primary: open}));
331
395
 
332
396
  await load();
333
397
  if (id && here()) open(id);