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.
145 lines
5.0 KiB
Templ
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>
|
|
}
|