feat(book-detail): add interactive half-star rating widget

The book detail page only displayed user ratings as static, non-clickable
stars. The full rating CRUD stack already existed in the backend
(media_ratings table, POST/GET/PUT/DELETE /api/media-items/:id/rating)
but nothing in the web UI could create or update a rating.

Replace the display-only renderStars output for the user rating with an
Alpine.js widget that:
- Renders 5 stars, each split into two transparent hit zones so the
  underlying 1-10 scale maps to half-star precision (left half = x.5,
  right half = whole star).
- Shows a live hover preview via a ratingHover state field.
- Saves the rating in place through POST /api/media-items/:id/rating
  (which upserts) and reflects the value immediately, with no full page
  reload.
- Displays the numeric value (e.g. "3.5 / 5") and a Clear button that
  issues DELETE to remove the rating.
- Reads the server-rendered value from a new data-rating attribute on
  <body> during the bookDetail component init().

The community rating block is left as a display-only renderStars render
since it is imported metadata, not a user rating.

templates/book_detail_templ.go is regenerated (also picking up templ
v0.3.1020 reformatting of the generated output).
This commit is contained in:
2026-07-30 13:08:00 -04:00
parent 1f5b0d0164
commit ca8c592496
3 changed files with 343 additions and 238 deletions
+84
View File
@@ -97,6 +97,9 @@ interface MetadataEditorState {
coverFile: Blob | null;
coverAction: string;
saving: boolean;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
toggleSection(section: string): void;
showMetadataEditor(): void;
hideMetadataEditor(): void;
@@ -104,6 +107,10 @@ interface MetadataEditorState {
generateCover(): Promise<void>;
removeCover(): void;
saveMetadata(): Promise<void>;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
}
@@ -136,6 +143,9 @@ Alpine.data("bookDetail", () => {
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
userRating: 0,
ratingHover: 0,
ratingSaving: false,
editorTags: initialTags,
tagSearch: "",
tagSuggestions: [] as TagSuggestion[],
@@ -178,6 +188,9 @@ Alpine.data("bookDetail", () => {
},
init() {
const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
const link = document.getElementById("back-link");
if (!link) return;
const storageKey = "book_detail_back";
@@ -207,6 +220,77 @@ Alpine.data("bookDetail", () => {
this.openSections[section] = !this.openSections[section];
},
starFill(i: number): string {
const display = this.ratingHover || this.userRating;
if (i * 2 <= display) {
return "color: var(--accent);";
} else if (i * 2 - 1 === display) {
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
}
return "color: var(--text-secondary);";
},
ratingText(): string {
if (this.userRating === 0) return "(not rated)";
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
},
async setRating(value: number) {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ rating: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save rating");
}
this.userRating = value;
this.ratingHover = 0;
showToast("Rating saved", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
async clearRating() {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to clear rating");
}
this.userRating = 0;
this.ratingHover = 0;
showToast("Rating cleared", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to clear rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
handleCoverUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];