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:
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }>
|
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
|
||||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||||
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
>
|
>
|
||||||
📖 Read Now
|
📖 Read Now
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Mark as Read / Unread toggle -->
|
||||||
|
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||||
|
<button
|
||||||
|
@click="toggleRead(false)"
|
||||||
|
:disabled="readSaving"
|
||||||
|
class="px-6 py-3 rounded-lg border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||||
|
>
|
||||||
|
<span x-show="!readSaving">✅ Mark as Unread</span>
|
||||||
|
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<button
|
||||||
|
@click="toggleRead(true)"
|
||||||
|
:disabled="readSaving"
|
||||||
|
class="px-6 py-3 rounded-lg border"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||||
|
>
|
||||||
|
<span x-show="!readSaving">📖 Mark as Read</span>
|
||||||
|
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<!-- Sync Progress -->
|
<!-- Sync Progress -->
|
||||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||||
<button
|
<button
|
||||||
|
|||||||
+573
-532
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -156,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
|
|||||||
return 0
|
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
|
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||||
func getAlternateSeries(data []byte) string {
|
func getAlternateSeries(data []byte) string {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ interface MetadataEditorState {
|
|||||||
userRating: number;
|
userRating: number;
|
||||||
ratingHover: number;
|
ratingHover: number;
|
||||||
ratingSaving: boolean;
|
ratingSaving: boolean;
|
||||||
|
conflictId: string;
|
||||||
|
conflictWinner: string;
|
||||||
|
readSaving: boolean;
|
||||||
toggleSection(section: string): void;
|
toggleSection(section: string): void;
|
||||||
showMetadataEditor(): void;
|
showMetadataEditor(): void;
|
||||||
hideMetadataEditor(): void;
|
hideMetadataEditor(): void;
|
||||||
@@ -111,6 +114,7 @@ interface MetadataEditorState {
|
|||||||
ratingText(): string;
|
ratingText(): string;
|
||||||
setRating(value: number): Promise<void>;
|
setRating(value: number): Promise<void>;
|
||||||
clearRating(): Promise<void>;
|
clearRating(): Promise<void>;
|
||||||
|
toggleRead(read: boolean): Promise<void>;
|
||||||
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +150,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
userRating: 0,
|
userRating: 0,
|
||||||
ratingHover: 0,
|
ratingHover: 0,
|
||||||
ratingSaving: false,
|
ratingSaving: false,
|
||||||
|
conflictId: "",
|
||||||
|
conflictWinner: "",
|
||||||
|
readSaving: false,
|
||||||
editorTags: initialTags,
|
editorTags: initialTags,
|
||||||
tagSearch: "",
|
tagSearch: "",
|
||||||
tagSuggestions: [] as TagSuggestion[],
|
tagSuggestions: [] as TagSuggestion[],
|
||||||
@@ -191,6 +198,12 @@ Alpine.data("bookDetail", () => {
|
|||||||
const ratingAttr = document.body.getAttribute("data-rating");
|
const ratingAttr = document.body.getAttribute("data-rating");
|
||||||
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
||||||
|
|
||||||
|
const conflictId = document.body.getAttribute("data-conflict-id");
|
||||||
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
||||||
|
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
||||||
|
this.conflictWinner =
|
||||||
|
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
||||||
|
|
||||||
const link = document.getElementById("back-link");
|
const link = document.getElementById("back-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
const storageKey = "book_detail_back";
|
const storageKey = "book_detail_back";
|
||||||
@@ -291,6 +304,79 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async toggleRead(read: boolean) {
|
||||||
|
if (this.readSaving) return;
|
||||||
|
this.readSaving = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
// Clear any active sync conflict first. Resolving arms a 10-minute
|
||||||
|
// suppression window so the progress write below does not spawn a new
|
||||||
|
// conflict. The winner only needs to be a valid source key; the final
|
||||||
|
// read/unread state is set by the progress write that follows.
|
||||||
|
if (this.conflictId && this.conflictWinner) {
|
||||||
|
const cr = await fetch(
|
||||||
|
`/api/conflicts/${this.conflictId}/resolve`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ winner: this.conflictWinner }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 400 means it was already resolved - treat as no conflict.
|
||||||
|
if (!cr.ok && cr.status !== 400) {
|
||||||
|
const err = await cr.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
err.error || err.message || "Failed to clear sync conflict",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read) {
|
||||||
|
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
|
||||||
|
// server silently ignores percentage < 0.005 when progress > 0.01.)
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ percentage: 1.0 }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to mark as read");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mark as Unread: DELETE the progress row. Notes, highlights and
|
||||||
|
// ratings are independent and are NOT affected.
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to mark as unread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
read ? "Marked as read" : "Marked as unread",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to update read state",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.readSaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleCoverUpload(event: Event) {
|
handleCoverUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user