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
+35 -6
View File
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }>
<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)) }>
@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="w-full px-4 py-3 flex items-center gap-4">
@@ -103,13 +103,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</button>
</div>
<!-- Rating Display -->
<div class="mb-6">
<div class="mb-6" @mouseleave="ratingHover = 0">
<span class="text-2xl">
@templ.Raw(renderStars(getBookRating(book.Rating)))
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span>
<button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
</span>
</template>
</span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<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>
</div>
<!-- Community Rating Display (from metadata) -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
File diff suppressed because it is too large Load Diff
+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];