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
+718
-319
No files matched your search
@@ -345,6 +345,20 @@ CREATE TABLE IF NOT EXISTS media_ratings (
|
|||||||
UNIQUE(media_item_id, user_id)
|
UNIQUE(media_item_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Create media_reviews table: the user's markdown book review. Sibling of
|
||||||
|
-- media_ratings (same uniqueness + cascade shape) but standalone — a review
|
||||||
|
-- without a rating (and vice versa) must work. Stored as RAW markdown and
|
||||||
|
-- rendered only at display sites (same doctrine as notes).
|
||||||
|
CREATE TABLE IF NOT EXISTS media_reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
review TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(media_item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Create media_notes table for user notes on media items
|
-- Create media_notes table for user notes on media items
|
||||||
CREATE TABLE IF NOT EXISTS media_notes (
|
CREATE TABLE IF NOT EXISTS media_notes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -602,6 +616,10 @@ CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user
|
|||||||
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
|
||||||
|
|
||||||
|
-- Reviews indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_media_item_id ON media_reviews(media_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_user_id ON media_reviews(user_id);
|
||||||
|
|
||||||
-- Notes and highlights indexes
|
-- Notes and highlights indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -352,6 +352,15 @@ type MediaRatings struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaReviews struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
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"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type OpdsTokens struct {
|
type OpdsTokens struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ type Querier interface {
|
|||||||
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
|
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
|
||||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||||
|
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
||||||
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||||
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
||||||
@@ -299,6 +300,7 @@ type Querier interface {
|
|||||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||||
|
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
|
||||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||||
// Get OPDS token
|
// Get OPDS token
|
||||||
@@ -573,6 +575,7 @@ type Querier interface {
|
|||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||||
|
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.0
|
// sqlc v1.31.1
|
||||||
// source: queries.sql
|
// source: queries.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -2262,6 +2262,20 @@ func (q *Queries) DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingPa
|
|||||||
return err
|
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
|
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
|
||||||
DELETE FROM processing_issues
|
DELETE FROM processing_issues
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -6518,6 +6532,29 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
|
|||||||
return items, nil
|
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
|
const GetNextRetryTime = `-- name: GetNextRetryTime :one
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
@@ -14976,6 +15013,36 @@ func (q *Queries) UpsertGlobalDashboardPreferences(ctx context.Context, arg Upse
|
|||||||
return i, err
|
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
|
const UpsertPanelData = `-- name: UpsertPanelData :one
|
||||||
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
|||||||
@@ -550,6 +550,21 @@ RETURNING *;
|
|||||||
-- name: DeleteMediaRating :exec
|
-- name: DeleteMediaRating :exec
|
||||||
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- 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 *;
|
||||||
|
|
||||||
|
-- name: GetMediaReview :one
|
||||||
|
SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: DeleteMediaReview :exec
|
||||||
|
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
|
||||||
|
|
||||||
-- Search Media Items queries
|
-- Search Media Items queries
|
||||||
-- name: SearchMediaItems :many
|
-- name: SearchMediaItems :many
|
||||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||||
|
|||||||
@@ -1012,6 +1012,111 @@ func (mh *MediaHandler) DeleteMediaRating(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "rating deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertMediaReview handles POST and PUT /api/media-items/:id/review.
|
||||||
|
// The review body is RAW MARKDOWN (same doctrine as notes): storage and
|
||||||
|
// every sync payload carry the source string; rendering happens only at
|
||||||
|
// display sites. An empty body deletes the review — clearing via the
|
||||||
|
// editor is a delete, not an empty row.
|
||||||
|
func (mh *MediaHandler) UpsertMediaReview(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Review string `json:"review" validate:"max=50000"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(req.Review)
|
||||||
|
if trimmed == "" {
|
||||||
|
err := mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
review, err := mh.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Review: req.Review,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, review)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaReview handles GET /api/media-items/:id/review
|
||||||
|
func (mh *MediaHandler) GetMediaReview(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
review, err := mh.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"review": nil})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, review)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMediaReview handles DELETE /api/media-items/:id/review
|
||||||
|
func (mh *MediaHandler) DeleteMediaReview(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mh.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "review deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
|
// GetMediaReadingProgress handles GET /api/media-items/:id/progress
|
||||||
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||||
userID := c.Get("user_id").(string)
|
userID := c.Get("user_id").(string)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type MediaDetail struct {
|
|||||||
|
|
||||||
// User-specific data
|
// User-specific data
|
||||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||||
|
Review *database.MediaReviews `json:"review,omitempty"` // raw markdown
|
||||||
Collections []database.Collections `json:"collections"`
|
Collections []database.Collections `json:"collections"`
|
||||||
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -1603,6 +1603,17 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
rating = &userRating
|
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
|
// Fetch collections
|
||||||
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
|
collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID)
|
||||||
|
|
||||||
@@ -1676,6 +1687,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
FileLocation: fileLocation,
|
FileLocation: fileLocation,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
|
Review: review,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
ReadingProgress: progress,
|
||||||
BookStats: bookStats,
|
BookStats: bookStats,
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
|
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
|
||||||
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
|
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)
|
// Progress routes (all authenticated users)
|
||||||
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
|
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
|
||||||
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
|
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
class="ml-2 text-xs underline hover:opacity-70"
|
class="ml-2 text-xs underline hover:opacity-70"
|
||||||
style="color: var(--text-secondary);"
|
style="color: var(--text-secondary);"
|
||||||
>Clear</button>
|
>Clear</button>
|
||||||
|
<!-- Review editor: markdown, same doctrine as notes -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openReviewEditor()"
|
||||||
|
class="ml-2 text-xs underline hover:opacity-70"
|
||||||
|
style="color: var(--text-secondary);"
|
||||||
|
x-text="hasReview ? 'Edit review' : 'Write review'"
|
||||||
|
></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);">
|
<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>
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
@@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
|
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
|
||||||
|
<!-- My Review (raw markdown hydrated client-side through the
|
||||||
|
notes pipeline — same sanitization as every note display) -->
|
||||||
|
if book.Review != nil && book.Review.Review != "" {
|
||||||
|
<div id="review-raw" class="hidden">{ book.Review.Review }</div>
|
||||||
|
<div id="review-card" class="card p-6 mb-6">
|
||||||
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">My Review</h3>
|
||||||
|
<div
|
||||||
|
id="review-body"
|
||||||
|
class="note-rich max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed"
|
||||||
|
style="color: var(--text-secondary)"
|
||||||
|
x-html="reviewHtml"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<!-- Synopsis -->
|
<!-- Synopsis -->
|
||||||
if book.Description.Valid && book.Description.String != "" {
|
if book.Description.Valid && book.Description.String != "" {
|
||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
@@ -477,6 +499,36 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
|||||||
if user.Role == "admin" {
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
}
|
}
|
||||||
|
<!-- Review editor: plain textarea over the RAW markdown (what
|
||||||
|
storage and sync carry); rendering happens only on display. -->
|
||||||
|
<div
|
||||||
|
x-show="reviewOpen"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 flex items-center justify-center z-50 p-4"
|
||||||
|
style="background-color: var(--surface-overlay);"
|
||||||
|
@keydown.escape.window="reviewOpen = false"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0" @click="reviewOpen = false"></div>
|
||||||
|
<div class="relative card p-6 w-full max-w-2xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Edit Review</h3>
|
||||||
|
<textarea
|
||||||
|
x-model="reviewDraft"
|
||||||
|
rows="12"
|
||||||
|
class="w-full rounded-xl border p-3 text-sm font-mono resize-y"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);"
|
||||||
|
placeholder="Markdown supported — **bold**, lists, footnotes…"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs" style="color: var(--text-secondary)">Markdown — renders like notes. Saving an empty review deletes it.</p>
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button type="button" @click="saveReview()" :disabled="reviewSaving" class="btn btn-primary">
|
||||||
|
<span x-show="!reviewSaving">Save review</span>
|
||||||
|
<span x-show="reviewSaving">Saving…</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="deleteReview()" :disabled="reviewSaving || !hasReview" class="btn btn-secondary" style="color: var(--status-danger, #e5484d);">Delete</button>
|
||||||
|
<button type="button" @click="reviewOpen = false" class="btn btn-secondary ml-auto">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+342
-315
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||||
|
import { renderNoteHtml } from "./markdown";
|
||||||
import { strToU8, zipSync } from "fflate";
|
import { strToU8, zipSync } from "fflate";
|
||||||
import {
|
import {
|
||||||
buildAnnotationRows,
|
buildAnnotationRows,
|
||||||
@@ -124,6 +125,14 @@ interface MetadataEditorState {
|
|||||||
ratingText(): string;
|
ratingText(): string;
|
||||||
setRating(value: number): Promise<void>;
|
setRating(value: number): Promise<void>;
|
||||||
clearRating(): 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>;
|
toggleRead(read: boolean): Promise<void>;
|
||||||
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||||
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
||||||
@@ -157,6 +166,13 @@ Alpine.data("bookDetail", () => {
|
|||||||
userRating: 0,
|
userRating: 0,
|
||||||
ratingHover: 0,
|
ratingHover: 0,
|
||||||
ratingSaving: false,
|
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: "",
|
conflictId: "",
|
||||||
conflictWinner: "",
|
conflictWinner: "",
|
||||||
readSaving: false,
|
readSaving: false,
|
||||||
@@ -467,6 +483,14 @@ Alpine.data("bookDetail", () => {
|
|||||||
const ratingAttr = document.body.getAttribute("data-rating");
|
const ratingAttr = document.body.getAttribute("data-rating");
|
||||||
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
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 conflictId = document.body.getAttribute("data-conflict-id");
|
||||||
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
||||||
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
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) {
|
async toggleRead(read: boolean) {
|
||||||
if (this.readSaving) return;
|
if (this.readSaving) return;
|
||||||
this.readSaving = true;
|
this.readSaving = true;
|
||||||
|
|||||||
Reference in new issue
Block a user