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:
@@ -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 {
|
||||
|
||||
+224
-232
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user