Skip to content

Commit 3f05ead

Browse files
vpeterssonclaude
andauthored
feat(server): add bulk asset management (enable/disable/edit/delete) (#3048)
* feat(server): add bulk asset management (enable/disable/edit/delete) Operators with large libraries previously had to act on assets one row at a time — a recurring forum request for years (#3046). The home page now has per-row selection checkboxes, a per-section select-all in each surface header, and a floating bulk-action bar (Enable, Disable, Edit, Delete) that appears whenever a selection is active. Backend: two new server-rendered endpoints reuse the shared _asset_table_response so the whole batch swaps the table partial and nudges the viewer once, instead of one round-trip per row. - assets_bulk_action — enable/disable/delete a selected set; delete goes through delete_asset_with_file so on-disk cleanup matches the per-asset path (#2908). - assets_bulk_update — applies common schedule fields (start/end dates, duration, play-from/until times, weekdays). Each group is opt-in via an apply_* flag so an operator only overwrites the fields they ticked, and everything is parsed before any row is mutated so a bad date/time toasts without a half-applied batch. Video duration stays owned by the probe task, mirroring assets_update. Frontend: selection lives in the homeApp Alpine state (selectedIds), so row :checked bindings re-evaluate after the table's 5s HTMX swap and the selection survives. setVisible() is re-published from the table partial on every swap to drive the select-all state and prune a selection of rows that have since disappeared. The bulk-edit modal reuses the existing flatpickr date/time inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): address Copilot review on bulk asset management - asset_ids filter: stop mark_safe'ing the JSON. It lands in a double-quoted x-init="…" attribute, so its own double quotes were closing the attribute early and breaking the Alpine expression. Return a plain str and let Django autoescaping entity-encode the quotes; the browser decodes them back to valid JSON. Adds a regression test asserting the rendered attribute is escaped. - bulk delete: pass delete_asset_with_file(nudge_viewer=False) per row and fire a single viewer reload after the batch, instead of one reload per asset. New keyword-only flag defaults True so the API / single-delete paths are unchanged. - bulk enable/disable: collapse the per-row save() loop into one queryset update() (no model signals here); its row count drives the toast and the empty-selection guard. - bulk duration: a blank field with apply_duration on no longer clobbers every asset's duration to 0 — it toasts and changes nothing (mirrors the per-asset edit form's preserve-on-blank intent). Negative values are rejected too, and the modal input is now required as client-side defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(server): collapse bulk_update writes into a single bulk_update() Second Copilot pass on #3048: assets_bulk_update still did one asset.save() per selected row, so a large selection (the point of bulk editing) fired N UPDATEs. Mutate the in-memory objects, track exactly which columns were touched (honouring the skip-videos-for-duration rule), and write them all with one Asset.objects.bulk_update(). An edit that ends up touching nothing (apply_dates ticked with both fields blank, or a duration-only edit on an all-video selection) now short-circuits with the "nothing to change" toast, since bulk_update() rejects an empty field list. Adds a test asserting exactly one UPDATE statement for a 5-asset batch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): keep bulk duration write off video rows; clear modal default Third Copilot pass on #3048: - The single bulk_update() folded `duration` into the all-rows field list, which writes every video row's stale in-memory duration back and can clobber a concurrent probe_video_duration UPDATE. Write duration on its own bulk_update() over only the non-video subset, so video rows are never touched by the duration column at all. Added a test spying on bulk_update to assert no video object is in a duration write. - The bulk-edit duration input was prefilled with 10, so toggling "Duration" and submitting would silently set every non-video asset to 10s. Drop the default to an empty placeholder and rely on the existing `required` to force an intentional value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): prune selection once across both sections; accurate bulk count Fourth Copilot pass on #3048: - syncVisibleIds(active, inactive): the table partial now publishes both sections' ids in one call and selectedIds is pruned once against their union. The previous two sequential setVisible() calls pruned on the first call while the other section's list was still stale, so a row that moved between sections (e.g. an enabled asset just disabled) lost its selection across the swap. - assets_bulk_update toast now reports only the rows actually written: a duration-only edit skips videos, so a mixed selection reports the non-video count instead of claiming every row was updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): bulk forms clear only on real success; O(n) section selection Fifth Copilot pass on #3048: - Bulk endpoints return 200 even when they refuse the input (bad date, partial time window, blank duration, "nothing to change") and just ride an error/info toast on HX-Trigger. The forms cleared the selection / closed the modal on any 2xx, wiping the operator's work when nothing was applied. New isSuccessResponse(event) helper gates the clear/close on a 2xx whose toast is absent or kind 'success'; wired into all four bulk forms (enable/disable/delete/edit). - sectionAllSelected/sectionSomeSelected did linear includes() against selectedIds, i.e. O(visible × selected) on every reactive re-evaluation. Build a Set once per call so they stay O(n) for the large selections bulk editing targets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): trim bulk ids; guard enable/disable on matched-rows count Sixth Copilot pass on #3048: - _bulk_ids() now strips each CSV segment, so a hand-built "a, b" (with spaces) still matches its rows instead of silently no-op'ing. - enable/disable take the count from a separate matched-rows count() rather than update()'s return value, which reports rows *changed* on some backends — re-enabling already-enabled assets would otherwise count 0 and (returning no toast) make the client treat it as success and clear the selection. A genuinely empty match now returns an info toast so the client's success gate keeps the selection. The empty bulk-delete case gets the same info toast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): info toast on bulk_update with no matching ids Seventh Copilot pass on #3048: assets_bulk_update returned a silent no-toast 2xx when the posted ids matched no rows (stale selection), so the client's success gate cleared the selection / closed the modal even though nothing applied. Return the same info toast the enable/disable path uses so the selection is kept. Adds a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(server): bulk_update via uniform queryset update()s; toast on no-op action Eighth Copilot pass on #3048: - assets_bulk_action no longer returns a silent no-toast 2xx for an unknown action or empty id set — an unknown action toasts an error, an empty selection an info toast, so the client's success gate keeps the selection instead of treating it as success. - assets_bulk_update applies the (uniform) new values with plain queryset update()s instead of loading every selected row and building a bulk_update() CASE. Shared fields go in one update(**shared); duration goes through a separate exclude(mimetype='video').update(), which keeps the never-touch-video-duration guarantee at the SQL level (no in-memory staleness). Row counts come from matched-rows count()s, not update()'s changed-rows return. Replaced the bulk_update-spy test with a SQL-level video-duration-untouched test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): bridge bulk hx-on handlers into Alpine via window event Ninth Copilot pass on #3048 — and a real runtime bug: the bulk forms' hx-on::after-request handlers called Alpine component methods (isSuccessResponse / clearSelection / closeBulkEdit / bulkDeleteOpen) directly, but hx-on runs in GLOBAL scope, so those are undefined there and the handler threw ReferenceError — the selection never cleared and the modals never closed on success. Fixed with the same dispatch-to-window bridge the Add modal already uses for 'asset-saved': hx-on now calls the global window.bulkSucceeded() gate and, on success, dispatches a 'bulk-done' window CustomEvent (with detail flags for which modal to close). The root x-data handles it via @bulk-done.window="onBulkDone($event)" in Alpine scope. Added a render test asserting the forms use the bridge and never call Alpine methods from hx-on. Also: assets_bulk_update now returns an info toast on an empty id set (was a silent no-toast 2xx the success gate would mis-read as success). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9094a20 commit 3f05ead

13 files changed

Lines changed: 1566 additions & 17 deletions

File tree

src/anthias_server/app/helpers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def remove_default_assets() -> None:
116116
asset.delete()
117117

118118

119-
def delete_asset_with_file(asset: Asset) -> None:
119+
def delete_asset_with_file(asset: Asset, *, nudge_viewer: bool = True) -> None:
120120
"""Delete an ``Asset`` row, remove its on-disk file (if owned), and
121121
nudge the viewer to advance past it.
122122
@@ -132,6 +132,10 @@ def delete_asset_with_file(asset: Asset) -> None:
132132
and a stray file is eventually cleaned up by the periodic
133133
``cleanup()`` orphan sweep — letting an unlink error block the DB
134134
delete would leave the operator unable to remove the row at all.
135+
136+
``nudge_viewer=False`` skips the per-row viewer reload so a bulk
137+
delete can fire a single reload after the whole batch instead of
138+
spamming the pub/sub channel once per asset (#3046).
135139
"""
136140
if asset.uri and asset.uri.startswith(settings['assetdir']):
137141
try:
@@ -147,4 +151,5 @@ def delete_asset_with_file(asset: Asset) -> None:
147151
# screen instead of finishing its remaining ``duration`` (#2430).
148152
# The viewer's reload handler checks whether the currently-shown
149153
# asset is still active and advances if not.
150-
ViewerPublisher.get_instance().send_to_viewer('reload')
154+
if nudge_viewer:
155+
ViewerPublisher.get_instance().send_to_viewer('reload')

src/anthias_server/app/static/sass/_styles.scss

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -477,23 +477,34 @@ label { text-align: left; }
477477
&[data-no-label]::before { display: none; }
478478
}
479479

480-
// Home rows: 2-column grid. Name spans both, schedule + duration
481-
// share row 2, toggle + actions share row 3. Right-aligned cells
482-
// align their labels to match.
480+
// Home rows: 2-column grid. The select checkbox (child 1) is
481+
// lifted out of flow into the top-left corner; name (child 2)
482+
// spans both columns, schedule + duration share row 2, toggle +
483+
// actions share row 3. Right-aligned cells align their labels to
484+
// match.
483485
tbody tr[data-asset-id] {
484486
display: grid;
485487
grid-template-columns: 1fr auto;
486488
gap: var(--space-2) var(--space-3);
487-
488-
td:nth-child(1) { grid-column: 1 / -1; }
489-
td:nth-child(2) { grid-column: 1 / 2; }
490-
td:nth-child(3) {
489+
position: relative;
490+
padding-left: 1.9rem;
491+
492+
td.asset-select-col {
493+
position: absolute;
494+
left: 0;
495+
top: var(--space-3);
496+
padding: 0;
497+
&::before { display: none; }
498+
}
499+
td:nth-child(2) { grid-column: 1 / -1; }
500+
td:nth-child(3) { grid-column: 1 / 2; }
501+
td:nth-child(4) {
491502
grid-column: 2 / 3;
492503
text-align: right;
493504
&::before { text-align: right; }
494505
}
495-
td:nth-child(4) { grid-column: 1 / 2; align-self: center; }
496-
td:nth-child(5) {
506+
td:nth-child(5) { grid-column: 1 / 2; align-self: center; }
507+
td:nth-child(6) {
497508
grid-column: 2 / 3;
498509
align-self: center;
499510
&::before { text-align: right; }
@@ -506,6 +517,39 @@ label { text-align: left; }
506517
}
507518
}
508519

520+
// Bulk-selection chrome (#3046): the leading checkbox column, the
521+
// per-section select-all in the surface header, and the selected-row
522+
// highlight. The checkbox itself reuses .app-check-input from the
523+
// forms section; .asset-select is just the clickable hit-area wrapper.
524+
.asset-select-col {
525+
width: 2.75rem;
526+
text-align: center;
527+
}
528+
529+
.asset-select {
530+
display: inline-flex;
531+
align-items: center;
532+
justify-content: center;
533+
cursor: pointer;
534+
margin: 0;
535+
536+
.app-check-input { margin: 0; cursor: pointer; }
537+
}
538+
539+
.surface__header-left {
540+
display: flex;
541+
align-items: center;
542+
gap: var(--space-3);
543+
min-width: 0;
544+
}
545+
546+
.asset-table tbody tr.is-selected td {
547+
background: var(--color-accent-wash);
548+
}
549+
.asset-table tbody tr.is-selected:hover td {
550+
background: var(--color-accent-wash);
551+
}
552+
509553
.asset-cell-name {
510554
display: flex;
511555
align-items: center;
@@ -1206,6 +1250,16 @@ label { text-align: left; }
12061250
background-position: center;
12071251
background-repeat: no-repeat;
12081252
}
1253+
// Partial selection (the section select-all when only some rows are
1254+
// ticked). Mirrors :checked but draws a dash instead of a tick.
1255+
&:indeterminate {
1256+
background-color: var(--color-link);
1257+
border-color: var(--color-link);
1258+
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-width='3' d='M4 8h8'/%3e%3c/svg%3e");
1259+
background-size: 0.85rem 0.85rem;
1260+
background-position: center;
1261+
background-repeat: no-repeat;
1262+
}
12091263
&:focus-visible {
12101264
outline: none;
12111265
box-shadow: 0 0 0 var(--ring-width) var(--color-focus-ring);
@@ -2335,3 +2389,85 @@ body.auth-body {
23352389
align-items: center;
23362390
text-decoration: none;
23372391
}
2392+
2393+
// -----------------------------------------------------------------------------
2394+
// Bulk asset actions (#3046)
2395+
// -----------------------------------------------------------------------------
2396+
// Floating bar pinned to the bottom of the viewport while a selection
2397+
// is active. Sits below the modal overlays (z 1050+) so the bulk-edit
2398+
// and bulk-delete dialogs render over it. The inner card carries the
2399+
// dark elevated look the navbar/footer use so it stands clear of the
2400+
// page surfaces.
2401+
.bulk-bar {
2402+
position: fixed;
2403+
left: 0;
2404+
right: 0;
2405+
bottom: var(--space-5);
2406+
z-index: 1040;
2407+
display: flex;
2408+
justify-content: center;
2409+
padding-inline: var(--space-4);
2410+
pointer-events: none;
2411+
}
2412+
.bulk-bar__inner {
2413+
pointer-events: auto;
2414+
display: flex;
2415+
align-items: center;
2416+
gap: var(--space-4);
2417+
flex-wrap: wrap;
2418+
justify-content: space-between;
2419+
width: 100%;
2420+
max-width: 720px;
2421+
padding: var(--space-3) var(--space-4);
2422+
background: var(--color-bg-elevated);
2423+
color: var(--color-text-on-dark);
2424+
border: 1px solid var(--color-border-on-dark);
2425+
border-radius: var(--radius-pill);
2426+
box-shadow: var(--shadow-lg);
2427+
}
2428+
.bulk-bar__count {
2429+
display: inline-flex;
2430+
align-items: center;
2431+
gap: var(--space-2);
2432+
font-size: 0.9rem;
2433+
white-space: nowrap;
2434+
i { font-size: 1.1rem; opacity: 0.85; }
2435+
strong { font-variant-numeric: tabular-nums; }
2436+
}
2437+
.bulk-bar__actions {
2438+
display: flex;
2439+
align-items: center;
2440+
gap: var(--space-2);
2441+
flex-wrap: wrap;
2442+
}
2443+
.bulk-bar__clear {
2444+
color: var(--color-text-on-dark);
2445+
background: transparent;
2446+
border-color: var(--color-border-on-dark);
2447+
}
2448+
2449+
@media (max-width: 640px) {
2450+
.bulk-bar__inner {
2451+
flex-direction: column;
2452+
align-items: stretch;
2453+
border-radius: var(--radius-lg);
2454+
}
2455+
.bulk-bar__actions { justify-content: center; }
2456+
}
2457+
2458+
// Bulk-edit modal: each schedule field group is a card whose body is
2459+
// only revealed once its "Change …" toggle is on. The is-on class
2460+
// lifts the card so the active groups read as the ones being written.
2461+
.bulk-field {
2462+
padding: var(--space-4);
2463+
border: 1px solid var(--color-border);
2464+
border-radius: var(--radius-lg);
2465+
margin-bottom: var(--space-3);
2466+
transition: border-color var(--t-fast), background var(--t-fast);
2467+
2468+
&.is-on {
2469+
border-color: var(--color-accent-edge);
2470+
background: var(--color-accent-wash);
2471+
}
2472+
}
2473+
.bulk-field__toggle { cursor: pointer; }

src/anthias_server/app/static/src/home.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare global {
1212
flatpickr: typeof flatpickrLib
1313
homeApp: () => HomeAppData
1414
fallbackCopyToClipboard: (text: string) => boolean
15+
bulkSucceeded: (event: Event) => boolean
1516
}
1617
}
1718

@@ -37,6 +38,8 @@ interface ToastStoreLike {
3738
push(kind: 'success' | 'error' | 'info', message: string): number
3839
}
3940

41+
type SectionKey = 'active' | 'inactive'
42+
4043
interface HomeAppData {
4144
mode: 'add' | 'edit' | null
4245
editAsset: AssetEdit | null
@@ -48,6 +51,11 @@ interface HomeAppData {
4851
uploadFileName: string
4952
uploadIndex: number
5053
uploadTotal: number
54+
// Bulk selection / actions (#3046)
55+
selectedIds: string[]
56+
visibleIds: Record<SectionKey, string[]>
57+
bulkEditOpen: boolean
58+
bulkDeleteOpen: boolean
5159
init(): void
5260
openAdd(): void
5361
openEdit(asset: AssetEdit): void
@@ -58,6 +66,17 @@ interface HomeAppData {
5866
bindFlatpickr(): void
5967
uploadFiles(input: HTMLInputElement): Promise<void>
6068
uploadOne(url: string, csrf: string, file: File): Promise<UploadResult>
69+
// Bulk selection helpers
70+
isSelected(id: string): boolean
71+
toggleSelect(id: string): void
72+
syncVisibleIds(activeIds: string[], inactiveIds: string[]): void
73+
sectionAllSelected(section: SectionKey): boolean
74+
sectionSomeSelected(section: SectionKey): boolean
75+
toggleSection(section: SectionKey, checked: boolean): void
76+
clearSelection(): void
77+
openBulkEdit(): void
78+
closeBulkEdit(): void
79+
onBulkDone(event: Event): void
6180
}
6281

6382
// 'ok' — file accepted and the asset row was created.
@@ -107,6 +126,10 @@ function homeApp(): HomeAppData {
107126
uploadFileName: '',
108127
uploadIndex: 0,
109128
uploadTotal: 0,
129+
selectedIds: [],
130+
visibleIds: { active: [], inactive: [] },
131+
bulkEditOpen: false,
132+
bulkDeleteOpen: false,
110133

111134
init(this: HomeAppData & { $watch: (k: string, cb: () => void) => void }) {
112135
// Re-bind Flatpickr every time the edit modal opens. The
@@ -117,6 +140,11 @@ function homeApp(): HomeAppData {
117140
this.$watch('editAsset', () =>
118141
requestAnimationFrame(() => this.bindFlatpickr()),
119142
)
143+
// The bulk-edit modal reuses the same .js-flatpickr-* inputs and
144+
// is also gated behind an x-if, so bind on open the same way.
145+
this.$watch('bulkEditOpen', () =>
146+
requestAnimationFrame(() => this.bindFlatpickr()),
147+
)
120148
},
121149

122150
bindFlatpickr() {
@@ -222,6 +250,83 @@ function homeApp(): HomeAppData {
222250
this.previewAsset = null
223251
},
224252

253+
// --- Bulk selection (#3046) ---------------------------------------
254+
// selectedIds is the source of truth; row checkboxes bind their
255+
// :checked to isSelected() so the selection survives the table's
256+
// 5s HTMX swap (the swapped rows re-evaluate against this state).
257+
isSelected(id) {
258+
return this.selectedIds.includes(id)
259+
},
260+
toggleSelect(id) {
261+
if (this.selectedIds.includes(id)) {
262+
this.selectedIds = this.selectedIds.filter((x) => x !== id)
263+
} else {
264+
this.selectedIds = [...this.selectedIds, id]
265+
}
266+
},
267+
// Called once from each rendered table partial (x-init re-fires
268+
// after every swap) so Alpine always knows the ids currently on
269+
// screen. Both sections are passed together and pruning happens
270+
// once against their union — pruning per-section would drop the
271+
// selection for a row that moved between sections (e.g. an enabled
272+
// asset just disabled), because the other section's list would
273+
// still be stale at that moment.
274+
syncVisibleIds(activeIds, inactiveIds) {
275+
this.visibleIds.active = activeIds
276+
this.visibleIds.inactive = inactiveIds
277+
const all = new Set([...activeIds, ...inactiveIds])
278+
this.selectedIds = this.selectedIds.filter((id) => all.has(id))
279+
},
280+
// Build a Set for membership so these stay O(visible + selected)
281+
// rather than O(visible × selected) — a selection of thousands (the
282+
// point of bulk editing) would otherwise lag on every reactive
283+
// re-evaluation.
284+
sectionAllSelected(section) {
285+
const ids = this.visibleIds[section]
286+
if (!ids.length) return false
287+
const sel = new Set(this.selectedIds)
288+
return ids.every((id) => sel.has(id))
289+
},
290+
sectionSomeSelected(section) {
291+
const ids = this.visibleIds[section]
292+
const sel = new Set(this.selectedIds)
293+
const n = ids.reduce((acc, id) => acc + (sel.has(id) ? 1 : 0), 0)
294+
return n > 0 && n < ids.length
295+
},
296+
toggleSection(section, checked) {
297+
const ids = this.visibleIds[section]
298+
if (checked) {
299+
const merged = new Set([...this.selectedIds, ...ids])
300+
this.selectedIds = [...merged]
301+
} else {
302+
const drop = new Set(ids)
303+
this.selectedIds = this.selectedIds.filter((id) => !drop.has(id))
304+
}
305+
},
306+
clearSelection() {
307+
this.selectedIds = []
308+
},
309+
openBulkEdit() {
310+
this.bulkEditOpen = true
311+
},
312+
closeBulkEdit() {
313+
this.bulkEditOpen = false
314+
},
315+
// Bridged from the bulk forms' hx-on::after-request via a global
316+
// window 'bulk-done' CustomEvent (hx-on runs in global scope and
317+
// can't reach Alpine methods — same dispatch-to-window bridge the
318+
// Add modal uses for 'asset-saved'). Only fires on a real success
319+
// (window.bulkSucceeded gates it), so a rejected edit leaves the
320+
// selection and modal intact. detail flags say which modal to close.
321+
onBulkDone(event) {
322+
const detail =
323+
(event as CustomEvent<{ closeDelete?: boolean; closeEdit?: boolean }>)
324+
.detail || {}
325+
this.clearSelection()
326+
if (detail.closeDelete) this.bulkDeleteOpen = false
327+
if (detail.closeEdit) this.closeBulkEdit()
328+
},
329+
225330
// Multi-file upload (issue #3045). The server's assets_upload
226331
// endpoint reads exactly one file per request
227332
// (request.FILES.get('file_upload')), so a single htmx form POST —
@@ -585,6 +690,30 @@ function fallbackCopyToClipboard(text: string): boolean {
585690
}
586691
window.fallbackCopyToClipboard = fallbackCopyToClipboard
587692

693+
// Did a bulk request actually succeed? The bulk endpoints return 200
694+
// even when they refuse the input (bad date, partial window, blank
695+
// duration, "nothing to change", empty/stale selection) and just ride
696+
// an error/info toast on the HX-Trigger header. The bulk forms call
697+
// this from hx-on::after-request — which runs in GLOBAL scope, not
698+
// Alpine's — so it lives on window rather than as a component method.
699+
// True only on a 2xx whose toast is absent or kind 'success'.
700+
function bulkSucceeded(event: Event): boolean {
701+
const detail = (
702+
event as CustomEvent<{ successful?: boolean; xhr?: XMLHttpRequest }>
703+
).detail
704+
if (!detail?.successful || !detail.xhr) return false
705+
const header = detail.xhr.getResponseHeader('HX-Trigger')
706+
if (!header) return true
707+
try {
708+
const kind = (JSON.parse(header) as { toast?: { kind?: string } })?.toast
709+
?.kind
710+
return kind === undefined || kind === 'success'
711+
} catch {
712+
return true
713+
}
714+
}
715+
window.bulkSucceeded = bulkSucceeded
716+
588717
window.homeApp = homeApp
589718

590719
function bootHomePage(): void {

0 commit comments

Comments
 (0)