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>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
</head>
|
</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) }")
|
@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="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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Rating Display -->
|
<!-- Rating Display -->
|
||||||
<div class="mb-6">
|
<div class="mb-6" @mouseleave="ratingHover = 0">
|
||||||
<span class="text-2xl">
|
<span class="text-2xl">
|
||||||
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
<template x-for="i in 5" :key="i">
|
||||||
</span>
|
<span style="position: relative; display: inline-block;">
|
||||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
<span :style="starFill(i)">★</span>
|
||||||
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
<!-- Community Rating Display (from metadata) -->
|
<!-- Community Rating Display (from metadata) -->
|
||||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
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;
|
coverFile: Blob | null;
|
||||||
coverAction: string;
|
coverAction: string;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
userRating: number;
|
||||||
|
ratingHover: number;
|
||||||
|
ratingSaving: boolean;
|
||||||
toggleSection(section: string): void;
|
toggleSection(section: string): void;
|
||||||
showMetadataEditor(): void;
|
showMetadataEditor(): void;
|
||||||
hideMetadataEditor(): void;
|
hideMetadataEditor(): void;
|
||||||
@@ -104,6 +107,10 @@ interface MetadataEditorState {
|
|||||||
generateCover(): Promise<void>;
|
generateCover(): Promise<void>;
|
||||||
removeCover(): void;
|
removeCover(): void;
|
||||||
saveMetadata(): Promise<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>;
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +143,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
coverFile: null as Blob | null,
|
coverFile: null as Blob | null,
|
||||||
coverAction: "keep",
|
coverAction: "keep",
|
||||||
saving: false,
|
saving: false,
|
||||||
|
userRating: 0,
|
||||||
|
ratingHover: 0,
|
||||||
|
ratingSaving: false,
|
||||||
editorTags: initialTags,
|
editorTags: initialTags,
|
||||||
tagSearch: "",
|
tagSearch: "",
|
||||||
tagSuggestions: [] as TagSuggestion[],
|
tagSuggestions: [] as TagSuggestion[],
|
||||||
@@ -178,6 +188,9 @@ Alpine.data("bookDetail", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
const ratingAttr = document.body.getAttribute("data-rating");
|
||||||
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
||||||
|
|
||||||
const link = document.getElementById("back-link");
|
const link = document.getElementById("back-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
const storageKey = "book_detail_back";
|
const storageKey = "book_detail_back";
|
||||||
@@ -207,6 +220,77 @@ Alpine.data("bookDetail", () => {
|
|||||||
this.openSections[section] = !this.openSections[section];
|
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) {
|
handleCoverUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user