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)
|
||||
);
|
||||
|
||||
-- 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 TABLE IF NOT EXISTS media_notes (
|
||||
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_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
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -352,6 +352,15 @@ type MediaRatings struct {
|
||||
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 {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -142,6 +142,7 @@ type Querier interface {
|
||||
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteMediaNote(ctx context.Context, arg DeleteMediaNoteParams) error
|
||||
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
|
||||
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
|
||||
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
|
||||
@@ -299,6 +300,7 @@ type Querier interface {
|
||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
|
||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||
// Get OPDS token
|
||||
@@ -573,6 +575,7 @@ type Querier interface {
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, 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)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -550,6 +550,21 @@ RETURNING *;
|
||||
-- name: DeleteMediaRating :exec
|
||||
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
|
||||
-- name: SearchMediaItems :many
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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
|
||||
func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
@@ -13,6 +13,7 @@ type MediaDetail struct {
|
||||
|
||||
// User-specific data
|
||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||
Review *database.MediaReviews `json:"review,omitempty"` // raw markdown
|
||||
Collections []database.Collections `json:"collections"`
|
||||
ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -154,6 +154,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>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);">
|
||||
<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>
|
||||
@@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
</div>
|
||||
</div>
|
||||
<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 -->
|
||||
if book.Description.Valid && book.Description.String != "" {
|
||||
<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" {
|
||||
@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)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+342
-315
File diff suppressed because it is too large.
Load diff
@@ -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