feat(web): book reviews — markdown, sibling of ratings, full API + page surface

media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).

API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.

Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).

sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
This commit is contained in:
John O'Keefe committed 2026-10-15 10:53:53 -04:00
1 parent fd6f0e3353
commit 2117ec6181
13 files changed
+751 -352

No files matched your search

+83
View File
@@ -1,6 +1,7 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
import { renderNoteHtml } from "./markdown";
import { strToU8, zipSync } from "fflate";
import {
buildAnnotationRows,
@@ -124,6 +125,14 @@ interface MetadataEditorState {
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
hasReview: boolean;
reviewHtml: string;
reviewOpen: boolean;
reviewDraft: string;
reviewSaving: boolean;
openReviewEditor(): void;
saveReview(): Promise<void>;
deleteReview(): Promise<void>;
toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
@@ -157,6 +166,13 @@ Alpine.data("bookDetail", () => {
userRating: 0,
ratingHover: 0,
ratingSaving: false,
// Review state: hasReview/reviewHtml hydrate from the server-rendered
// raw markdown in #review-raw; reviewDraft is the editor's working copy.
hasReview: false,
reviewHtml: "",
reviewOpen: false,
reviewDraft: "",
reviewSaving: false,
conflictId: "",
conflictWinner: "",
readSaving: false,
@@ -467,6 +483,14 @@ Alpine.data("bookDetail", () => {
const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
// Hydrate the review card from the server-rendered raw markdown.
const reviewRaw = document.getElementById("review-raw");
if (reviewRaw) {
const source = reviewRaw.textContent || "";
this.hasReview = source.trim().length > 0;
this.reviewHtml = renderNoteHtml(source);
}
const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
@@ -573,6 +597,65 @@ Alpine.data("bookDetail", () => {
}
},
openReviewEditor() {
// Seed the draft from the CURRENT rendered source: the raw markdown
// lives in #review-raw when a review exists, otherwise start empty.
const reviewRaw = document.getElementById("review-raw");
this.reviewDraft = reviewRaw ? reviewRaw.textContent || "" : "";
this.reviewOpen = true;
},
async saveReview() {
if (this.reviewSaving) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ review: this.reviewDraft }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save review");
}
// Reload so the server-rendered card (or its absence) matches.
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save review",
"error",
);
this.reviewSaving = false;
}
},
async deleteReview() {
if (this.reviewSaving || !this.hasReview) return;
this.reviewSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete review");
}
window.location.reload();
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete review",
"error",
);
this.reviewSaving = false;
}
},
async toggleRead(read: boolean) {
if (this.readSaving) return;
this.readSaving = true;