From 4a68fe7cab4468d12815cf492bf5132abdc432c5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 18 Oct 2026 12:42:50 -0400 Subject: [PATCH] fix(conflicts): the status filter, select-all, and bulk actions actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They never did: filterConflicts/toggleAllConflicts/updateBulkActions were referenced in the templ but defined nowhere, and the bulk buttons called bulkResolve()/bulkDismiss() with no arguments against functions expecting (strategy, conflictIds). - conflict cards carry data-status; the status dropdown filters them client-side (pure client state — allowed to be JS), applied on page init and re-applied after every partial refresh, with a 'no conflicts match this filter' note when everything is filtered out - select-all toggles only the visible cards; the bulk bar shows the selected count and resolve/dismiss now gather the checked ids and the chosen strategy (the bar's own buttons use resolveSelected()/ dismissSelected() wrappers) - dead onchange= globals became Alpine @change handlers on the server- rendered controls; partial swaps re-bind via Alpine.initTree (already in refreshConflicts) and re-apply filter + bulk state Visible change: the dropdown's stated default (Unresolved Only) now actually applies on load, so resolved conflicts are hidden until the filter is changed. --- templates/conflicts.templ | 15 +-- templates/conflicts_templ.go | 183 +++++++++++++++++++---------------- web/src/conflicts.ts | 86 ++++++++++++++++ 3 files changed, 195 insertions(+), 89 deletions(-) diff --git a/templates/conflicts.templ b/templates/conflicts.templ index 79507fc7..65164415 100644 --- a/templates/conflicts.templ +++ b/templates/conflicts.templ @@ -8,7 +8,7 @@ import "bookhoard/internal/handlers" templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) { @@ -22,14 +22,14 @@ templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailRespons } for _, conflict := range conflicts { -
+
@@ -51,6 +51,9 @@ templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailRespons
} + } // ConflictStatsBar renders the total/unresolved/resolved stat cards. @@ -110,7 +113,7 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
- -
diff --git a/templates/conflicts_templ.go b/templates/conflicts_templ.go index 4c890bb4..bb0e6798 100644 --- a/templates/conflicts_templ.go +++ b/templates/conflicts_templ.go @@ -34,7 +34,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -66,20 +66,33 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"border-left-width: 4px; border-left-color: var(--status-warning);\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -87,82 +100,86 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Type: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Created: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Created: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if conflict.ResolvedBy != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "| Resolved by: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "| Resolved by: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

No conflicts match this filter.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } return nil }) } @@ -184,51 +201,51 @@ func ConflictStatsBar(total int, unresolved int) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(total) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 59, Col: 82} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Total
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(total) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 63, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Unresolved
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Total
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 67, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
Resolved
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Unresolved
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
Resolved
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -256,12 +273,12 @@ func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResp }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -269,7 +286,7 @@ func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResp if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -277,7 +294,7 @@ func ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResp if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -301,34 +318,34 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Sync Conflicts - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Sync Conflicts - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 = []any{"theme-" + user.Theme} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) + var templ_7745c5c3_Var16 = []any{"theme-" + user.Theme} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -336,7 +353,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -344,7 +361,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Sync Conflicts

Resolve conflicts when reading progress differs across devices

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Sync Conflicts

Resolve conflicts when reading progress differs across devices

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -352,7 +369,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
0 conflicts selected
0 conflicts selected

Loading conflicts...

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Resolve Selected

Loading conflicts...

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -368,7 +385,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

No Conflicts

Your devices are in sync! No conflicts to resolve.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

No Conflicts

Your devices are in sync! No conflicts to resolve.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -376,7 +393,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Resolve Conflict

Book Title

Resolve Conflict

Book Title

Resolution Options

Manual Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

Resolution Options

Manual Progress

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -392,7 +409,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index 3d07fa1d..d14453ff 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -12,11 +12,84 @@ async function refreshConflicts(): Promise { }); const list = document.getElementById("conflicts-list"); if (list) Alpine.initTree(list); + filterConflicts(); + updateBulkActions(); } catch (error) { console.error("Failed to refresh conflicts:", error); } } +// --- Client-side filter and bulk selection ------------------------------- +// Pure functions over the server-rendered conflict cards (rendering +// ownership: JS decides what shows; templ rendered what they look like). + +function filterConflicts(): void { + const select = document.getElementById( + "status-filter", + ) as HTMLSelectElement | null; + const filter = select?.value ?? "unresolved"; + let total = 0; + let visible = 0; + document.querySelectorAll(".conflict-item").forEach((item) => { + total++; + const status = item.dataset.status ?? "unresolved"; + const show = filter === "all" || status === filter; + item.style.display = show ? "" : "none"; + if (show) visible++; + }); + const empty = document.getElementById("filter-empty"); + if (empty) empty.classList.toggle("hidden", visible > 0 || total === 0); + // Cards hidden by the filter can't stay selected. + if (visible !== total) updateBulkActions(); +} + +function toggleAllConflicts(): void { + const selectAll = document.getElementById( + "select-all-conflicts", + ) as HTMLInputElement | null; + if (!selectAll) return; + document.querySelectorAll(".conflict-item").forEach((item) => { + if (item.style.display === "none") return; + const box = item.querySelector(".conflict-checkbox"); + if (box) box.checked = selectAll.checked; + }); + updateBulkActions(); +} + +function getSelectedConflictIds(): string[] { + return Array.from( + document.querySelectorAll(".conflict-checkbox:checked"), + ) + .map((box) => box.dataset.conflictId || "") + .filter((id) => id !== ""); +} + +function updateBulkActions(): void { + const selected = getSelectedConflictIds(); + const bar = document.getElementById("bulk-actions"); + if (bar) bar.classList.toggle("hidden", selected.length === 0); + const count = document.getElementById("selected-count"); + if (count) count.textContent = String(selected.length); +} + +function resolveSelected(): void { + const ids = getSelectedConflictIds(); + if (ids.length === 0) return; + const strategyEl = document.getElementById( + "bulk-strategy", + ) as HTMLSelectElement | null; + const strategy = (strategyEl?.value ?? "most_recent") as + | "most_recent" + | "highest_progress"; + void bulkResolve(strategy, ids); +} + +function dismissSelected(): void { + const ids = getSelectedConflictIds(); + if (ids.length === 0) return; + void bulkDismiss(ids); +} + async function resolveConflict( conflictId: string, winner: string, @@ -168,9 +241,17 @@ export { refreshConflicts, resolveConflict, showResolveModal, + filterConflicts, + toggleAllConflicts, + updateBulkActions, + resolveSelected, + dismissSelected, }; Alpine.data("conflicts", () => ({ + init() { + filterConflicts(); + }, bulkDismiss, bulkResolve, dismissAllResolved, @@ -179,4 +260,9 @@ Alpine.data("conflicts", () => ({ refreshConflicts, resolveConflict, showResolveModal, + filterConflicts, + toggleAllConflicts, + updateBulkActions, + resolveSelected, + dismissSelected, }));