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:
@@ -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