From 2117ec61810d09d3086d005bd52edc738aa25228 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 15 Oct 2026 10:53:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20book=20reviews=20=E2=80=94=20markd?= =?UTF-8?q?own,=20sibling=20of=20ratings,=20full=20API=20+=20page=20surfac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- database/schema/schema.sql | 18 + internal/database/db.go | 2 +- internal/database/models.go | 11 +- internal/database/querier.go | 5 +- internal/database/queries.sql.go | 69 ++- internal/database/queries/queries.sql | 15 + internal/handlers/media.go | 105 ++++ internal/handlers/media_detail.go | 1 + internal/router/frontend.go | 12 + internal/router/media.go | 7 + templates/book_detail.templ | 52 ++ templates/book_detail_templ.go | 723 +++++++++++++------------- web/src/book-detail.ts | 83 +++ 13 files changed, 751 insertions(+), 352 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 68aa157c..57763c56 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -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); diff --git a/internal/database/db.go b/internal/database/db.go index 371b20c8..486aa36f 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.0 +// sqlc v1.31.1 package database diff --git a/internal/database/models.go b/internal/database/models.go index 7f1b87eb..03424606 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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"` diff --git a/internal/database/querier.go b/internal/database/querier.go index cc7685b9..32adbd61 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 3bba4175..88764de2 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index e669f544..c8b67283 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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 diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 14f8abfb..9ca6f93b 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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) diff --git a/internal/handlers/media_detail.go b/internal/handlers/media_detail.go index f5d72525..5c948570 100644 --- a/internal/handlers/media_detail.go +++ b/internal/handlers/media_detail.go @@ -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"` diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 808e027a..ca099d2b 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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, diff --git a/internal/router/media.go b/internal/router/media.go index 6228bfb8..62bea48b 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -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) diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 20895570..81f3b24a 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -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 + + @@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
+ + if book.Review != nil && book.Review.Review != "" { + +
+

My Review

+
+
+ } if book.Description.Valid && book.Description.String != "" {
@@ -477,6 +499,36 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb if user.Role == "admin" { @MetadataEditorModal(book) } + +
+
+
+

Edit Review

+ +

Markdown — renders like notes. Saving an empty review deletes it.

+
+ + + +
+
+
@ErrorToast(errorMessage) diff --git a/templates/book_detail_templ.go b/templates/book_detail_templ.go index f0835ddd..bf7d8432 100644 --- a/templates/book_detail_templ.go +++ b/templates/book_detail_templ.go @@ -391,7 +391,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -411,7 +411,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 169, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 177, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -434,7 +434,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var18 templ.SafeURL templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 176, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 184, Col: 125} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -447,7 +447,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Series.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 180, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 188, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -465,7 +465,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesNumber.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 182, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 190, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -485,7 +485,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.ReadingDirection.String)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 187, Col: 142} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 195, Col: 142} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -504,7 +504,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.AgeRating.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 190, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 198, Col: 123} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -529,7 +529,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(book.StoryArc.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 196, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 204, Col: 122} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -557,7 +557,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var24 templ.SafeURL templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 203, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 211, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -570,7 +570,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tag) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 207, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 215, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -586,12 +586,35 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.Review != nil && book.Review.Review != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(book.Review.Review) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 228, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

My Review

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.Description.Valid && book.Description.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

Synopsis

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

Synopsis

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -599,13 +622,13 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

Comic Summary

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

Comic Summary

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -613,41 +636,41 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if book.MetadataNotes.Valid && book.MetadataNotes.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

Metadata Notes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

Metadata Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(book.MetadataNotes.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 237, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 259, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingProgress != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "

Reading Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Reading Progress

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ActiveConflict != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -655,832 +678,832 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

Progress conflict detected — click \"Sync Progress\" to review and resolve.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

Progress conflict detected — click \"Sync Progress\" to review and resolve.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Progress

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">

Progress

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 283, Col: 134} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "%

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

Page

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 266, Col: 87} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Page

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32) + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.CurrentPage.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 266, Col: 131} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 288, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if book.ReadingProgress.LastReadAt.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

Last Read

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone)) + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.TotalPages.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 272, Col: 140} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 288, Col: 131} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.ReadingProgress.LastSyncSource.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

Source

") + if book.ReadingProgress.LastReadAt.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

Last Read

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String) + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 278, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 294, Col: 140} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.BookStats != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

Your Reading Stats

Time Reading

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.BookStats.TotalMinutes >= 60 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

") + if book.ReadingProgress.LastSyncSource.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

Source

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60)) + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(book.ReadingProgress.LastSyncSource.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 292, Col: 157} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 300, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.BookStats != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

Your Reading Stats

Time Reading

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.BookStats.TotalMinutes >= 60 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes)) + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 294, Col: 119} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 314, Col: 157} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", book.BookStats.TotalMinutes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 316, Col: 119} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

Sessions

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 299, Col: 114} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

Pages Synced

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

Sessions

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages)) + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.SessionCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 303, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 321, Col: 114} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

Finished

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.BookStats.Finished { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

✓

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

—

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

Metadata

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.Publisher.Valid && book.Publisher.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Publisher

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

Pages Synced

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", book.BookStats.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 323, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 325, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

Finished

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.BookStats.Finished { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

✓

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

—

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.DatePublished.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

Published

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

Metadata

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.Publisher.Valid && book.Publisher.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

Publisher

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(book.Publisher.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 329, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 345, Col: 69} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

ISBN

") + if book.DatePublished.Valid { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

Published

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(book.DatePublished.Time.Format("01-02-2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 335, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 351, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Language.Valid && book.Language.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

Language

") + if book.Isbn.Valid && book.Isbn.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

ISBN

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(book.Isbn.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 341, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 357, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Edition.Valid && book.Edition.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "

Edition

") + if book.Language.Valid && book.Language.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

Language

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(book.Language.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 347, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 363, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.PageCount.Valid && book.PageCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

Pages

") + if book.Edition.Valid && book.Edition.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

Edition

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(book.Edition.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 353, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 369, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Genre.Valid && book.Genre.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

Genre

") + if book.PageCount.Valid && book.PageCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "

Pages

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(book.PageCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 359, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 375, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

Series Count

") + if book.Genre.Valid && book.Genre.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "

Genre

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(book.Genre.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 365, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 381, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, " items

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Volume.Valid && book.Volume.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "

Volume

Vol. ") + if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "

Series Count

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(book.SeriesCount.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 371, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 387, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " items

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.Imprint.Valid && book.Imprint.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

Imprint

") + if book.Volume.Valid && book.Volume.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "

Volume

Vol. ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(book.Volume.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 377, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 393, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "

Copyright

") + if book.Imprint.Valid && book.Imprint.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

Imprint

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(book.Imprint.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 383, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 399, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "

Manga Type

") + if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "

Copyright

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(book.CopyrightYear.Int32) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 389, Col: 118} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 405, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if book.ScanInformation.Valid && book.ScanInformation.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "

Scan Info

") + if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "

Manga Type

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ReplaceAll(book.MangaType.String, "_", " ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 395, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 411, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "

Alternate Series

") + if book.ScanInformation.Valid && book.ScanInformation.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "

Scan Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(book.ScanInformation.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 401, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 417, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if len(book.Contributors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "

Contributors

") + if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "

Alternate Series

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(altSeries) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 407, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 423, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "

Format

") + if len(book.Contributors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "

Contributors

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(book.Contributors, ", ")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 429, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

Format

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(book.MimeType.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 412, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 434, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.FileSize.Valid && book.FileSize.Int64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

File Size

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 417, Col: 82} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if user.Role == "admin" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

Location

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

File Size

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation) + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(formatFileSize(book.FileSize.Int64)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 423, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 439, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "
") + if user.Role == "admin" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "

Location

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(book.FileLocation) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 445, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "Goodreads ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "Goodreads ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Goodreads ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "Open Library ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Open Library ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "Google Books ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if book.Asin.Valid && book.Asin.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Google Books ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if book.Isbn.Valid && book.Isbn.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "Amazon ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if book.WebUrl.Valid && book.WebUrl.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">Amazon ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(getDomainName(book.WebUrl.String)) + } + if book.WebUrl.Valid && book.WebUrl.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"chip hover:opacity-90\" style=\"background-color: var(--accent-muted); color: var(--accent); text-decoration: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(book.Collections) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(book.Collections) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

Collections

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, col := range book.Collections { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var69 string - templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon.String) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 467, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 488, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail.templ`, Line: 489, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1502,11 +1525,15 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs return templ_7745c5c3_Err } } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "

Edit Review

Markdown — renders like notes. Saving an empty review deletes it.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = ErrorToast(errorMessage).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index b1f48d76..9aeb5974 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -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; clearRating(): Promise; + hasReview: boolean; + reviewHtml: string; + reviewOpen: boolean; + reviewDraft: string; + reviewSaving: boolean; + openReviewEditor(): void; + saveReview(): Promise; + deleteReview(): Promise; toggleRead(read: boolean): Promise; resolveConflict(conflictId: string, winner: string): Promise; restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise; @@ -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;