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.
286 lines
13 KiB
Templ
286 lines
13 KiB
Templ
package templates
|
|
|
|
import "bookhoard/internal/handlers"
|
|
|
|
// 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" @change="toggleAllConflicts()"/>
|
|
<span class="text-sm font-semibold" style="color: var(--text-primary);">Select All</span>
|
|
</label>
|
|
</div>
|
|
if len(conflicts) == 0 {
|
|
<div class="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>
|
|
}
|
|
for _, conflict := range conflicts {
|
|
<div class="conflict-item card p-6" data-conflict-id={ conflict.ID } data-status={ conflict.ResolutionStatus } style="border-left-width: 4px; border-left-color: var(--status-warning);">
|
|
<div class="flex justify-between items-start mb-4 gap-4">
|
|
<div class="flex items-start space-x-4">
|
|
<input
|
|
type="checkbox"
|
|
class="conflict-checkbox w-5 h-5 rounded mt-1"
|
|
data-conflict-id={ conflict.ID }
|
|
@change="updateBulkActions()"
|
|
/>
|
|
<div class="flex items-start gap-3">
|
|
<span style="color: var(--status-warning);">
|
|
@Icon("alert", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">{ conflict.MediaItemTitle }</h3>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Type: { conflict.ConflictType }</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button @click={ "showResolveModal(" + JSONArg(conflict.ID) + ")" } class="btn btn-primary">Resolve</button>
|
|
</div>
|
|
<div class="text-sm" style="color: var(--text-secondary)">
|
|
Created: { FormatInTimezone(conflict.CreatedAt, user.Timezone) }
|
|
if conflict.ResolvedBy != "" {
|
|
| Resolved by: { conflict.ResolvedBy }
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
<p id="filter-empty" class="hidden text-center p-4" style="color: var(--text-secondary)">
|
|
No conflicts match this filter.
|
|
</p>
|
|
}
|
|
|
|
// 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"
|
|
@change="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="resolveSelected()" class="btn btn-primary text-sm">
|
|
@Icon("check", "h-4 w-4")
|
|
Resolve Selected
|
|
</button>
|
|
<button @click="dismissSelected()" 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);">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Resolve Conflict</h2>
|
|
<p class="text-sm" id="conflict-book-title" style="color: var(--text-secondary);">Book Title</p>
|
|
</div>
|
|
<button @click="hideResolveModal()" class="icon-btn" aria-label="Close">
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<form id="conflict-resolution-form" @submit="handleResolveSubmit($event)">
|
|
<input type="hidden" id="conflict-id"/>
|
|
<div id="conflict-details" class="mb-6"></div>
|
|
<div class="card p-4 mb-6">
|
|
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Resolution Options</h3>
|
|
<div class="space-y-3 mb-6">
|
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
|
<input type="radio" name="winner" value="koreader" class="w-4 h-4" required/>
|
|
<div class="flex-1">
|
|
<span class="font-semibold" style="color: var(--text-primary);">Keep KOReader Progress</span>
|
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your KOReader device</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
|
<input type="radio" name="winner" value="kobo" class="w-4 h-4"/>
|
|
<div class="flex-1">
|
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Kobo Progress</span>
|
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from your Kobo device</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
|
<input type="radio" name="winner" value="web" class="w-4 h-4"/>
|
|
<div class="flex-1">
|
|
<span class="font-semibold" style="color: var(--text-primary);">Keep Web Progress</span>
|
|
<p class="text-xs" style="color: var(--text-secondary);">Use progress from the web interface</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-center gap-3 p-3 rounded-lg cursor-pointer card transition-colors" style="border-color: var(--border);">
|
|
<input type="radio" name="winner" value="manual" class="w-4 h-4"/>
|
|
<div class="flex-1">
|
|
<span class="font-semibold" style="color: var(--text-primary);">Manual Override</span>
|
|
<p class="text-xs" style="color: var(--text-secondary);">Specify a custom progress value</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div id="manual-override" class="hidden mb-4 p-4 rounded-lg" style="background-color: var(--bg-primary); border: 1px solid var(--border);">
|
|
<h4 class="font-semibold mb-3" style="color: var(--text-primary);">Manual Progress</h4>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Percentage (0-100)</label>
|
|
<input
|
|
type="number"
|
|
id="manual-percentage"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Page Number</label>
|
|
<input
|
|
type="number"
|
|
id="manual-page"
|
|
min="1"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">EPUB CFI</label>
|
|
<input
|
|
type="text"
|
|
id="manual-epubcfi"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Chapter</label>
|
|
<input
|
|
type="number"
|
|
id="manual-chapter"
|
|
min="1"
|
|
class="input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="flex items-center space-x-3 cursor-pointer">
|
|
<input type="checkbox" id="apply-to-all" class="w-5 h-5 rounded"/>
|
|
<span style="color: var(--text-primary);">Apply this resolution to all future conflicts</span>
|
|
</label>
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary);">Resolution Reason (optional)</label>
|
|
<input
|
|
type="text"
|
|
id="resolution-reason"
|
|
class="input"
|
|
placeholder="e.g., Device was offline, using most recent progress"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end space-x-3 flex-wrap">
|
|
<button type="button" @click="hideResolveModal()" class="btn btn-secondary">Cancel</button>
|
|
<button type="button" @click="deleteConflict()" class="btn btn-danger">Dismiss</button>
|
|
<button type="submit" class="btn btn-primary">Resolve Conflict</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|