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

+68 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.0
// sqlc v1.31.1
// source: queries.sql
package database
@@ -2262,6 +2262,20 @@ func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingPa
return err
}
const DeleteMediaReview = `-- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type DeleteMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error {
_, err := q.db.Exec(ctx, DeleteMediaReview, arg.MediaItemID, arg.UserID)
return err
}
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues
WHERE id = $1
@@ -6518,6 +6532,29 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil
}
const GetMediaReview = `-- name: GetMediaReview :one
SELECT id, media_item_id, user_id, review, created_at, updated_at FROM media_reviews WHERE media_item_id = $1 AND user_id = $2
`
type GetMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, GetMediaReview, arg.MediaItemID, arg.UserID)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT
CASE
@@ -14976,6 +15013,36 @@ func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg Upse
return i, err
}
const UpsertMediaReview = `-- name: UpsertMediaReview :one
INSERT INTO media_reviews (media_item_id, user_id, review)
VALUES ($1, $2, $3)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
review = EXCLUDED.review,
updated_at = NOW()
RETURNING id, media_item_id, user_id, review, created_at, updated_at
`
type UpsertMediaReviewParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Review string `db:"review" json:"review"`
}
func (q *Queries) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) {
row := q.db.QueryRow(ctx, UpsertMediaReview, arg.MediaItemID, arg.UserID, arg.Review)
var i MediaReviews
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.Review,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpsertPanelData = `-- name: UpsertPanelData :one
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
VALUES ($1, $2, $3, $4)