Files
bookhoard/templates/admin_hash_conflicts.templ
T
john-okeefe 8004cb81a5 feat(ui): admin Hash Conflicts page and nav entry
New /admin/hash-conflicts page (admin-only) listing pending
content-duplicate groups. Each group card shows the library, a
shortened SHA-256, and one row per copy with title, author, path,
size, and per-copy reading-data counts (progress, highlights,
bookmarks, notes, collections) - copies that own user data are
highlighted so the keep choice is informed.

Per copy: 'Keep this copy' merges the other copies' child rows into
it and deletes them. Per group: 'Keep both' for intentional
duplicates. Both confirm first, resolve via htmx POST, and swap the
card for a resolved confirmation inline. The confirmation fragment is
built inline in the handler rather than the templates package
(templates imports handlers; a back-import would be a cycle).

Empty state shown when no conflicts are pending. Adds a 'Hash
Conflicts' entry to the admin sidebar section between Libraries and
Users.
2026-08-14 08:52:33 -04:00

145 lines
5.0 KiB
Templ

package templates
import "fmt"
// HashConflictItemData is one copy in a conflict group.
type HashConflictItemData struct {
ID string
Title string
Author string
FilePath string
FileSize int64
UsageSummary string
HasReadingData bool
}
// HashConflictData is one pending conflict group.
type HashConflictData struct {
ID string
LibraryName string
SHA256 string
SHAShort string
CreatedAt string
Items []HashConflictItemData
}
templ HashConflictCard(conflict HashConflictData) {
<div class="card p-6" id={ "conflict-" + conflict.ID }>
<div class="mb-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="mb-1 flex items-center gap-2">
@Icon("copy", "h-5 w-5 shrink-0")
<h4 class="text-lg font-semibold" style="color: var(--text-primary)">Duplicate content</h4>
<span class="badge status-pending">{ fmt.Sprintf("%d copies", len(conflict.Items)) }</span>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Library: <span class="font-medium">{ conflict.LibraryName }</span>
<span class="mx-2">·</span>
SHA-256: <code class="text-xs">{ conflict.SHAShort }</code>
</p>
<p class="mt-1 text-xs" style="color: var(--text-secondary)">
These files are byte-identical. Keep one copy (reading data from the others is merged in), or keep both if the duplicates are intentional.
</p>
</div>
<div class="shrink-0">
<button
hx-post={ "/api/admin/hash-conflicts/" + conflict.ID + "/resolve" }
hx-vals='{"action": "keep_all"}'
hx-target={ "#conflict-" + conflict.ID }
hx-swap="outerHTML"
hx-confirm="Keep all copies and stop flagging this group?"
class="btn btn-secondary text-sm"
>
@Icon("check-circle", "h-4 w-4")
Keep both
</button>
</div>
</div>
<div class="space-y-3">
for _, item := range conflict.Items {
@HashConflictItemCardWrapper(item, conflict.ID)
}
</div>
</div>
}
// HashConflictItemCardWrapper renders an item card with its parent conflict ID
// for the resolve endpoint targeting.
templ HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) {
<div class="flex items-start justify-between gap-4 rounded-lg border p-4" style="border-color: var(--border);">
<div class="min-w-0 flex-1">
<p class="font-medium truncate" style="color: var(--text-primary)">{ item.Title }</p>
if item.Author != "" {
<p class="text-sm truncate" style="color: var(--text-secondary)">{ item.Author }</p>
}
<p class="mt-1 text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
<p class="mt-1 text-xs" style="color: var(--text-secondary)">
{ fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)) }
if item.HasReadingData {
<span class="ml-2 font-medium" style="color: var(--accent);">{ item.UsageSummary }</span>
} else {
<span class="ml-2">{ item.UsageSummary }</span>
}
</p>
</div>
<div class="shrink-0">
<button
hx-post={ "/api/admin/hash-conflicts/" + conflictID + "/resolve" }
hx-vals={ fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID) }
hx-target={ "#conflict-" + conflictID }
hx-swap="outerHTML"
hx-confirm="Keep this copy and merge the other copy's reading data into it?"
class="btn btn-secondary text-sm"
>
@Icon("check", "h-4 w-4")
Keep this copy
</button>
</div>
</div>
}
// HashConflictResolved is swapped in place of a card after resolution.
templ AdminHashConflicts(user User, conflicts []HashConflictData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Hash Conflicts - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class={ "theme-" + user.Theme }>
@Header(user, "/admin/hash-conflicts")
<main class="p-8">
<div class="mx-auto max-w-4xl">
<div class="mb-8">
<div>
<div class="mb-1 flex items-center gap-3">
<span class="grid h-10 w-10 place-items-center rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("copy", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Hash Conflicts</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Books with identical content stored at more than one path</p>
</div>
</div>
if len(conflicts) == 0 {
<div class="card p-8 text-center">
<span class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("check-circle", "h-6 w-6")
</span>
<p class="text-sm" style="color: var(--text-secondary)">No content duplicates detected.</p>
</div>
} else {
<div class="space-y-4">
for _, conflict := range conflicts {
@HashConflictCard(conflict)
}
</div>
}
</div>
</main>
</body>
</html>
}