feat(book-detail): add Mark as Read / Unread toggle button
The book detail page had no way to mark a book finished or reset its
read state from the UI. Reading state is modelled by reading_progress
alone, where 'read' is the canonical signal percentage >= 1.0 (used by
the dashboard Recently Read collection, analytics, and sync priority).
Add a single toggle button in the action row (after Read Now) whose
label is server-rendered from completion state:
- not read -> "Mark as Read" -> PUT /api/media-items/:id/progress
{ percentage: 1.0 }
- read -> "Mark as Unread" -> DELETE /api/media-items/:id/progress
Mark as Unread cannot use PUT { percentage: 0 }: the progress handler
silently ignores percentage < 0.005 when existing progress > 0.01
(internal/handlers/media.go anti-regression guard), so DELETE is the
only reliable reset.
If the book has an active sync mismatch (an unresolved sync_conflicts
row), the toggle resolves it first via POST /api/conflicts/:id/resolve
before writing progress. Order matters: resolving sets resolved_at,
arming the 10-minute HasRecentConflictResolution suppression window so
the subsequent progress write does not spawn a brand-new conflict. The
resolve winner is any valid source key from the conflict data (prefers
"web"); it does not affect the final state, which the progress write
sets. A 400 "already resolved" response is tolerated.
Notes, highlights, and ratings are independent of reading_progress (they
reference media_items, not progress) and are never affected by the
toggle. After toggling the page reloads so the progress card, Sync
Progress button, and conflict banner re-render server-side.
- templates/utils.go: add conflictWinnerSource and conflictID helpers.
- templates/book_detail.templ: data-conflict-id/winner on <body> and the
toggle button.
- web/src/book-detail.ts: toggleRead() + conflictId/conflictWinner/
readSaving state (read from <body> in init()).
- templates/book_detail_templ.go regenerated.
This commit is contained in:
@@ -2,9 +2,11 @@ package templates
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -156,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// conflictWinnerSource returns a valid source key from a conflict's
|
||||
// conflict_data map, to pass as the "winner" when resolving it. It prefers
|
||||
// "web" (since the user is acting via the web UI) and otherwise falls back to
|
||||
// the lexicographically smallest key. The chosen winner does not affect the
|
||||
// final read/unread state, which is set by a subsequent progress write; it only
|
||||
// needs to be a key present in the conflict data so the resolve endpoint
|
||||
// accepts it and arms its 10-minute suppression window.
|
||||
func conflictWinnerSource(c *handlers.ConflictDetailResponse) string {
|
||||
if c == nil || len(c.ConflictData) == 0 {
|
||||
return ""
|
||||
}
|
||||
if _, ok := c.ConflictData["web"]; ok {
|
||||
return "web"
|
||||
}
|
||||
keys := make([]string, 0, len(c.ConflictData))
|
||||
for k := range c.ConflictData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// conflictID returns the active conflict's ID, or "" when there is none. Used
|
||||
// to render a data-conflict-id attribute the frontend can read.
|
||||
func conflictID(c *handlers.ConflictDetailResponse) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||
func getAlternateSeries(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user