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:
1 parent
fd6f0e3353
commit
2117ec6181
13 files changed
+751
-352
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user