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:
2026-07-30 13:31:18 -04:00
parent 33c69e7c71
commit bf83492bf7
4 changed files with 721 additions and 533 deletions
+86
View File
@@ -100,6 +100,9 @@ interface MetadataEditorState {
userRating: number;
ratingHover: number;
ratingSaving: boolean;
conflictId: string;
conflictWinner: string;
readSaving: boolean;
toggleSection(section: string): void;
showMetadataEditor(): void;
hideMetadataEditor(): void;
@@ -111,6 +114,7 @@ interface MetadataEditorState {
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
}
@@ -146,6 +150,9 @@ Alpine.data("bookDetail", () => {
userRating: 0,
ratingHover: 0,
ratingSaving: false,
conflictId: "",
conflictWinner: "",
readSaving: false,
editorTags: initialTags,
tagSearch: "",
tagSuggestions: [] as TagSuggestion[],
@@ -191,6 +198,12 @@ Alpine.data("bookDetail", () => {
const ratingAttr = document.body.getAttribute("data-rating");
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");
if (!link) return;
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) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];