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

+12
View File
@@ -1603,6 +1603,17 @@ func registerFrontendRoutes(cfg *Config) {
rating = &userRating
}
// Fetch the user's review (raw markdown; rendered client-side
// through the notes pipeline).
var review *database.MediaReviews
userReview, err := cfg.Queries.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
})
if err == nil {
review = &userReview
}
// Fetch collections
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
@@ -1676,6 +1687,7 @@ func registerFrontendRoutes(cfg *Config) {
MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating,
Review: review,
Collections: collections,
ReadingProgress: progress,
BookStats: bookStats,
+7
View File
@@ -25,6 +25,13 @@ func registerMediaRoutes(cfg *Config) {
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
// Media review routes (all authenticated users). Raw markdown in,
// raw markdown out — rendered only at display sites.
protected.POST("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.GET("/media-items/:id/review", cfg.MediaHandler.GetMediaReview)
protected.PUT("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
protected.DELETE("/media-items/:id/review", cfg.MediaHandler.DeleteMediaReview)
// Progress routes (all authenticated users)
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)