fix(reader): stabilize chrome panels, bookmarks end-to-end, dead UI removal

Phase 0 of the reader redesign:

- Panels no longer render under the top/bottom bars: sidebars get
  measured insets (same resize/safe-area mechanism as the viewport);
  panel max-height now derives from the bounded sidebar instead of a
  100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
  /api/media-items/:id/bookmarks (create/delete route through
  AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
  referencing nonexistent updated_at column, frontend posts to the
  real API with per-format position (CFI vs page), live list with
  jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
  a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
  unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
  theme-tokyo-night.
This commit is contained in:
2026-08-14 09:05:33 -04:00
parent 03cb4c7869
commit ba95cc3e8b
11 changed files with 449 additions and 295 deletions
+165
View File
@@ -131,6 +131,25 @@ type UpdateMediaHighlightRequest struct {
NoteID string `json:"note_id"`
}
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
type CreateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Position string `json:"position" validate:"max=100"`
Notes string `json:"notes" validate:"max=10000"`
CfiPosition string `json:"cfi_position" validate:"max=255"`
PageNumber int32 `json:"page_number"`
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
}
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
type UpdateMediaBookmarkRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Notes string `json:"notes" validate:"max=10000"`
Position string `json:"position" validate:"max=100"`
}
type MediaHandler struct {
db *database.Queries
worker *services.Worker
@@ -1677,6 +1696,152 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
func (mh *MediaHandler) GetMediaBookmarks(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"})
}
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
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, bookmarks)
}
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
func (mh *MediaHandler) CreateMediaBookmark(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 CreateMediaBookmarkRequest
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()})
}
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
// to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Title: req.Title,
Position: req.Position,
Notes: req.Notes,
PageNumber: req.PageNumber,
ChapterNumber: req.ChapterNumber,
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, result.Bookmark)
}
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
Title: req.Title,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, bookmark)
}
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) UpdateMediaBookmark(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"})
}
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
var req UpdateMediaBookmarkRequest
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()})
}
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
Title: req.Title,
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
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, bookmark)
}
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
bookmarkID := c.Param("bookmarkId")
bookmarkUUID, err := uuid.Parse(bookmarkID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
}
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// SearchMediaItems handles GET /api/media-items/search
// Supports two modes:
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns