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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user