feat(web): templ partials replace the last UI twins (collections grid, conflicts list)
Both pages had a templ render (initial load) and a TypeScript twin (innerHTML rebuild on refresh) of the same fragment. The twins are deleted; the server-rendered markup is now the only markup. - collections: the library switcher swaps in the new books-grid partial (GET /collections/:id/books-grid) instead of re-rendering cards from the /api/collections JSON. Book loading is extracted into Config.loadCollectionBooks, shared verbatim by the page and the partial (smart / dashboard-query / manual branches, family-lens scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot drift. - conflicts: resolve/dismiss refreshes swap the new list partial (GET /conflicts/list) with the stats bar as an out-of-band swap, instead of the TS twin — which rendered a degraded card (no checkboxes, no timestamps), pointed at a window.showResolveModal that does not exist, and updated stats via element ids the SSR never had. - shims.d.ts: window.htmx typing (ajax/process) for TS callers. Visibility note: the books-grid partial inherits its family-lens scoping from the shared loader (identical to the detail page); a dedicated hidden-library pin test for the partial route is still worth adding in the next test pass.
This commit is contained in:
1 parent
1fdf306f2d
commit
f1844591b4
9 files changed
+1078
-889
No files matched your search
+115
-90
@@ -2,96 +2,10 @@ package templates
|
||||
|
||||
import "bookhoard/internal/handlers"
|
||||
|
||||
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Sync Conflicts - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="conflicts" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/conflicts")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
class="input"
|
||||
>
|
||||
<option value="unresolved">Unresolved Only</option>
|
||||
<option value="all">All Conflicts</option>
|
||||
<option value="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulk-actions" class="mt-4 hidden">
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select
|
||||
id="bulk-strategy"
|
||||
class="input w-auto"
|
||||
>
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||
@Icon("check", "h-4 w-4")
|
||||
Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
<div id="empty-state" class="hidden card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
// ConflictsListContent renders the inner content of #conflicts-list:
|
||||
// bulk-select controls, the empty state, or the conflict cards. Shared by
|
||||
// the conflicts page and the /conflicts/list partial route.
|
||||
templ ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse) {
|
||||
<div id="select-all-container" class="mb-4 hidden">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" id="select-all-conflicts" class="w-5 h-5 rounded" onchange="toggleAllConflicts()"/>
|
||||
@@ -137,6 +51,117 @@ templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total in
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ConflictStatsBar renders the total/unresolved/resolved stat cards.
|
||||
templ ConflictStatsBar(total int, unresolved int) {
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--text-primary);">{ total }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Total</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-warning);">{ unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Unresolved</div>
|
||||
</div>
|
||||
<div class="stat-card text-center py-3">
|
||||
<div class="text-2xl font-bold" style="color: var(--status-success);">{ total - unresolved }</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Resolved</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ConflictsRefreshFragment is the /conflicts/list partial response: a
|
||||
// fresh list plus the stats bar as an out-of-band swap. The client swaps
|
||||
// this instead of re-rendering conflict cards from JSON (rendering
|
||||
// ownership: templ renders, JS only swaps).
|
||||
templ ConflictsRefreshFragment(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int) {
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
@ConflictsListContent(user, conflicts)
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg" hx-swap-oob="true">
|
||||
@ConflictStatsBar(total, unresolved)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int, unresolved int, errorMessage string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Sync Conflicts - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body x-data="conflicts" class={ "theme-" + user.Theme }>
|
||||
@Header(user, "/conflicts")
|
||||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
|
||||
@Icon("alert", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Sync Conflicts</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">Resolve conflicts when reading progress differs across devices</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<select
|
||||
id="status-filter"
|
||||
onchange="filterConflicts()"
|
||||
class="input"
|
||||
>
|
||||
<option value="unresolved">Unresolved Only</option>
|
||||
<option value="all">All Conflicts</option>
|
||||
<option value="resolved">Resolved Only</option>
|
||||
</select>
|
||||
<button @click="dismissAllResolved()" class="btn btn-secondary">Dismiss All Resolved</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="mt-4 grid grid-cols-3 gap-4 max-w-lg">
|
||||
@ConflictStatsBar(total, unresolved)
|
||||
</div>
|
||||
<div id="bulk-actions" class="mt-4 hidden">
|
||||
<div class="card p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">
|
||||
<span id="selected-count">0</span> conflicts selected
|
||||
</span>
|
||||
<select
|
||||
id="bulk-strategy"
|
||||
class="input w-auto"
|
||||
>
|
||||
<option value="most_recent">Most Recent</option>
|
||||
<option value="highest_progress">Highest Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="bulkResolve()" class="btn btn-primary text-sm">
|
||||
@Icon("check", "h-4 w-4")
|
||||
Resolve Selected
|
||||
</button>
|
||||
<button @click="bulkDismiss()" class="btn btn-danger text-sm">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading" class="hidden text-center py-16" style="color: var(--text-secondary)">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p>Loading conflicts...</p>
|
||||
</div>
|
||||
<div id="empty-state" class="hidden card text-center py-16">
|
||||
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
|
||||
@Icon("check-circle", "h-7 w-7")
|
||||
</span>
|
||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Conflicts</h3>
|
||||
<p style="color: var(--text-secondary)">Your devices are in sync! No conflicts to resolve.</p>
|
||||
</div>
|
||||
<div id="conflicts-list" class="space-y-4">
|
||||
@ConflictsListContent(user, conflicts)
|
||||
</div>
|
||||
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4" style="background-color: var(--surface-overlay);">
|
||||
<div class="card p-6 w-full max-w-4xl my-8" style="box-shadow: var(--shadow-pop);">
|
||||
|
||||
Reference in new issue
Block a user