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:
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.30.0
|
||||||
// source: queries.sql
|
// source: queries.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -11274,7 +11274,7 @@ SET
|
|||||||
title = $2,
|
title = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
position = $4,
|
position = $4,
|
||||||
updated_at = NOW()
|
last_modified_at = NOW()
|
||||||
WHERE id = $1 AND user_id = $5
|
WHERE id = $1 AND user_id = $5
|
||||||
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
|
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -2481,7 +2481,7 @@ SET
|
|||||||
title = $2,
|
title = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
position = $4,
|
position = $4,
|
||||||
updated_at = NOW()
|
last_modified_at = NOW()
|
||||||
WHERE id = $1 AND user_id = $5
|
WHERE id = $1 AND user_id = $5
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,25 @@ type UpdateMediaHighlightRequest struct {
|
|||||||
NoteID string `json:"note_id"`
|
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 {
|
type MediaHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
worker *services.Worker
|
worker *services.Worker
|
||||||
@@ -1677,6 +1696,152 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|||||||
return c.NoContent(http.StatusNoContent)
|
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
|
// SearchMediaItems handles GET /api/media-items/search
|
||||||
// Supports two modes:
|
// Supports two modes:
|
||||||
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
||||||
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
|
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
|
||||||
|
|
||||||
|
// Bookmark routes (all authenticated users)
|
||||||
|
protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks)
|
||||||
|
protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark)
|
||||||
|
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
|
||||||
|
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
|
||||||
|
|
||||||
// Admin-only media routes
|
// Admin-only media routes
|
||||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||||
|
|||||||
+63
-75
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
|
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string {
|
||||||
config := map[string]interface{}{
|
config := map[string]interface{}{
|
||||||
"mediaItemId": metadata.MediaItemID,
|
"mediaItemId": metadata.MediaItemID,
|
||||||
"fileUrl": metadata.FileURL,
|
"fileUrl": metadata.FileURL,
|
||||||
@@ -27,6 +27,23 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
|
|||||||
config["savedTotalPages"] = progress.TotalPages
|
config["savedTotalPages"] = progress.TotalPages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(bookmarks) > 0 {
|
||||||
|
items := make([]map[string]interface{}, 0, len(bookmarks))
|
||||||
|
for _, b := range bookmarks {
|
||||||
|
var page any
|
||||||
|
if b.PageNumber != nil {
|
||||||
|
page = *b.PageNumber
|
||||||
|
}
|
||||||
|
items = append(items, map[string]interface{}{
|
||||||
|
"id": b.ID,
|
||||||
|
"title": b.Title,
|
||||||
|
"positionLabel": b.Position,
|
||||||
|
"cfi": b.CfiPosition,
|
||||||
|
"page": page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
config["bookmarks"] = items
|
||||||
|
}
|
||||||
jsonBytes, _ := json.Marshal(config)
|
jsonBytes, _ := json.Marshal(config)
|
||||||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||||||
}
|
}
|
||||||
@@ -47,36 +64,32 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
|||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="readerShell"
|
x-data="readerShell"
|
||||||
x-init={ readerInitExpr(metadata, progress) }
|
x-init={ readerInitExpr(metadata, progress, bookmarks) }
|
||||||
class="theme-tokyo-night h-screen overflow-hidden"
|
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
||||||
>
|
>
|
||||||
@ReaderChrome(user, metadata, progress)
|
@ReaderChrome(user, metadata, progress)
|
||||||
<!-- Dockable Panels Container -->
|
<!-- Dockable Panels Container -->
|
||||||
<div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
|
<div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
|
||||||
<!-- Left Sidebar (TOC) -->
|
<!-- Left Sidebar (TOC) -->
|
||||||
<div id="left-sidebar" class="absolute left-0 top-0 bottom-0 pointer-events-auto flex flex-col">
|
<div id="left-sidebar" class="absolute left-0 pointer-events-auto flex flex-col" style="top: 56px; bottom: 56px;">
|
||||||
<div id="toc-panel" x-show="tocOpen" x-transition class="panel-container pointer-events-auto" data-panel="toc">
|
<div id="toc-panel" x-show="tocOpen" x-transition class="panel-container pointer-events-auto" data-panel="toc">
|
||||||
@ReaderTOCPanel(metadata)
|
@ReaderTOCPanel(metadata)
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Right Sidebar (Settings, Navigator, Bookmarks) -->
|
|
||||||
<div id="right-sidebar" class="absolute right-0 top-0 bottom-0 pointer-events-auto flex flex-col">
|
|
||||||
<div id="settings-panel" x-show="settingsOpen" x-transition class="panel-container pointer-events-auto" data-panel="settings">
|
|
||||||
@ReaderSettingsPanel()
|
|
||||||
</div>
|
|
||||||
<div id="navigator-panel" x-show="navigatorOpen" x-transition class="panel-container pointer-events-auto" data-panel="navigator">
|
|
||||||
@ReaderNavigatorPanel()
|
|
||||||
</div>
|
|
||||||
<div id="bookmarks-panel" x-show="bookmarksOpen" x-transition class="panel-container pointer-events-auto" data-panel="bookmarks">
|
|
||||||
@ReaderBookmarksPanel(bookmarks)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="reader-viewport" class="absolute inset-x-0" style="top: 56px; bottom: 56px;">
|
<!-- Right Sidebar (Settings, Bookmarks) -->
|
||||||
<foliate-view id="reader-view" class="block w-full h-full"></foliate-view>
|
<div id="right-sidebar" class="absolute right-0 pointer-events-auto flex flex-col" style="top: 56px; bottom: 56px;">
|
||||||
|
<div id="settings-panel" x-show="settingsOpen" x-transition class="panel-container pointer-events-auto" data-panel="settings">
|
||||||
|
@ReaderSettingsPanel()
|
||||||
|
</div>
|
||||||
|
<div id="bookmarks-panel" x-show="bookmarksOpen" x-transition class="panel-container pointer-events-auto" data-panel="bookmarks">
|
||||||
|
@ReaderBookmarksPanel()
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@DictionaryPopup()
|
</div>
|
||||||
</body>
|
<div id="reader-viewport" class="absolute inset-x-0" style="top: 56px; bottom: 56px;">
|
||||||
|
<foliate-view id="reader-view" class="block w-full h-full"></foliate-view>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,8 +199,8 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
|||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
|
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents">📖</button>
|
||||||
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark">🏷️</button>
|
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position">🏷️</button>
|
||||||
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Notes">📝</button>
|
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmarks">📝</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,14 +224,6 @@ templ ReaderSettingsPanel() {
|
|||||||
<!-- Display settings -->
|
<!-- Display settings -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="font-semibold mb-2">Display</h3>
|
<h3 class="font-semibold mb-2">Display</h3>
|
||||||
<label class="block mb-2">
|
|
||||||
Chrome Behavior
|
|
||||||
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
|
|
||||||
<option value="auto-hide">Auto Hide</option>
|
|
||||||
<option value="always-visible">Always Visible</option>
|
|
||||||
<option value="hide-on-scroll">Hide on Scroll</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="block mb-2">
|
<label class="block mb-2">
|
||||||
Progress Mode
|
Progress Mode
|
||||||
<select name="progress_mode" x-model="progressMode" @change="applyProgressMode()" class="w-full mt-1 px-3 py-2 rounded border">
|
<select name="progress_mode" x-model="progressMode" @change="applyProgressMode()" class="w-full mt-1 px-3 py-2 rounded border">
|
||||||
@@ -356,26 +361,7 @@ templ ReaderTOCPanel(_ ReaderMetadata) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ReaderNavigatorPanel() {
|
templ ReaderBookmarksPanel() {
|
||||||
<div
|
|
||||||
class="dockable-panel"
|
|
||||||
data-panel="navigator"
|
|
||||||
data-side="right"
|
|
||||||
x-ref="navigatorPanel"
|
|
||||||
>
|
|
||||||
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" @click="toggleWindowShade($refs.navigatorPanel)">
|
|
||||||
<h3 class="panel-title font-semibold">🗺️ Navigator</h3>
|
|
||||||
<div class="panel-controls flex items-center gap-2">
|
|
||||||
<button class="window-shade-toggle">─</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="panel-content p-2 overflow-hidden">
|
|
||||||
<div id="navigator-viewport" class="relative w-full h-full"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ ReaderBookmarksPanel(bookmarks []Bookmark) {
|
|
||||||
<div
|
<div
|
||||||
class="dockable-panel"
|
class="dockable-panel"
|
||||||
data-panel="bookmarks"
|
data-panel="bookmarks"
|
||||||
@@ -389,32 +375,34 @@ templ ReaderBookmarksPanel(bookmarks []Bookmark) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-content p-4 overflow-y-auto">
|
<div class="panel-content p-4 overflow-y-auto">
|
||||||
if len(bookmarks) > 0 {
|
<div id="bookmarks-list" class="space-y-2">
|
||||||
<div id="bookmarks-list" class="space-y-2">
|
<template x-for="bookmark in bookmarkItems" :key="bookmark.id">
|
||||||
for _, bookmark := range bookmarks {
|
<div class="flex items-center gap-1 group">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent="goToBookmarkTarget($el.dataset.cfi)"
|
@click.prevent="goToBookmark(bookmark)"
|
||||||
data-cfi={ bookmark.CfiPosition }
|
class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2"
|
||||||
class="block py-2 hover:bg-gray-700 rounded px-2"
|
|
||||||
>
|
>
|
||||||
<span class="font-medium">{ bookmark.Title }</span>
|
<span class="font-medium block truncate" x-text="bookmark.title"></span>
|
||||||
<span class="text-xs ml-2" style="color: var(--text-secondary)">
|
<span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
|
||||||
{ bookmark.Position }
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
}
|
<button
|
||||||
</div>
|
@click="deleteBookmark(bookmark.id)"
|
||||||
} else {
|
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
|
title="Delete bookmark"
|
||||||
}
|
aria-label="Delete bookmark"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="bookmarkItems.length === 0">
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<button @click="addBookmark()" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button @click="addBookmark()" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
+ Add Bookmark
|
+ Add Bookmark
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ DictionaryPopup() {
|
|
||||||
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
|
|
||||||
}
|
|
||||||
|
|||||||
+90
-189
File diff suppressed because one or more lines are too long
+116
-23
@@ -382,7 +382,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tocOpen: false,
|
tocOpen: false,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
bookmarksOpen: false,
|
bookmarksOpen: false,
|
||||||
navigatorOpen: false,
|
bookmarkItems: [] as {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
positionLabel: string;
|
||||||
|
cfi: string;
|
||||||
|
page: number | null;
|
||||||
|
}[],
|
||||||
tocItems: [] as any[],
|
tocItems: [] as any[],
|
||||||
mediaItemId: "" as string,
|
mediaItemId: "" as string,
|
||||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
@@ -446,8 +452,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
savedCfi?: string;
|
savedCfi?: string;
|
||||||
savedPage?: number;
|
savedPage?: number;
|
||||||
savedTotalPages?: number;
|
savedTotalPages?: number;
|
||||||
|
bookmarks?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
positionLabel: string;
|
||||||
|
cfi: string;
|
||||||
|
page: number | null;
|
||||||
|
}[];
|
||||||
}) {
|
}) {
|
||||||
this.mediaItemId = config.mediaItemId;
|
this.mediaItemId = config.mediaItemId;
|
||||||
|
this.bookmarkItems = config.bookmarks ?? [];
|
||||||
this.settings = await loadSettings();
|
this.settings = await loadSettings();
|
||||||
if (this.settings) {
|
if (this.settings) {
|
||||||
this.progressMode = this.settings.progress_mode || "pages";
|
this.progressMode = this.settings.progress_mode || "pages";
|
||||||
@@ -499,7 +513,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.setStyles?.(this.buildCSS());
|
this.renderer.setStyles?.(this.buildCSS());
|
||||||
}
|
}
|
||||||
this.view.addEventListener("load", (e: any) => {
|
this.view.addEventListener("load", (e: any) => {
|
||||||
const { doc, index } = e.detail;
|
const { doc } = e.detail;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = "/static/reader-fonts.css";
|
link.href = "/static/reader-fonts.css";
|
||||||
@@ -587,8 +601,19 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const vp = document.getElementById("reader-viewport");
|
const vp = document.getElementById("reader-viewport");
|
||||||
if (!top || !bot || !vp) return;
|
if (!top || !bot || !vp) return;
|
||||||
const margin = 6;
|
const margin = 6;
|
||||||
vp.style.setProperty("top", `${top.offsetHeight + margin}px`);
|
const topInset = `${top.offsetHeight + margin}px`;
|
||||||
vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`);
|
const bottomInset = `${bot.offsetHeight + margin}px`;
|
||||||
|
vp.style.setProperty("top", topInset);
|
||||||
|
vp.style.setProperty("bottom", bottomInset);
|
||||||
|
// Sidebars (TOC/settings/bookmarks panels) must sit below/above the
|
||||||
|
// chrome bars, tracking their measured heights (safe-area insets, wrap).
|
||||||
|
for (const id of ["left-sidebar", "right-sidebar"]) {
|
||||||
|
const sidebar = document.getElementById(id);
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.style.setProperty("top", topInset);
|
||||||
|
sidebar.style.setProperty("bottom", bottomInset);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setupViewportInsets() {
|
setupViewportInsets() {
|
||||||
const top = document.getElementById("reader-topbar");
|
const top = document.getElementById("reader-topbar");
|
||||||
@@ -639,7 +664,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (tocItem) {
|
if (tocItem) {
|
||||||
if (tocItem.label) {
|
if (tocItem.label) {
|
||||||
const boundaryIdx = this.chapterBoundaries.findIndex(
|
const boundaryIdx = this.chapterBoundaries.findIndex(
|
||||||
(b: any) => b.tocItem === tocItem,
|
(b: any) => b.label === tocItem.label,
|
||||||
);
|
);
|
||||||
if (boundaryIdx !== -1) {
|
if (boundaryIdx !== -1) {
|
||||||
body.chapter = boundaryIdx;
|
body.chapter = boundaryIdx;
|
||||||
@@ -730,9 +755,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
toggleBookmarks() {
|
toggleBookmarks() {
|
||||||
this.bookmarksOpen = !this.bookmarksOpen;
|
this.bookmarksOpen = !this.bookmarksOpen;
|
||||||
},
|
},
|
||||||
toggleNavigator() {
|
|
||||||
this.navigatorOpen = !this.navigatorOpen;
|
|
||||||
},
|
|
||||||
toggleWindowShade(panelEl: HTMLElement) {
|
toggleWindowShade(panelEl: HTMLElement) {
|
||||||
panelEl.classList.toggle("panel-collapsed");
|
panelEl.classList.toggle("panel-collapsed");
|
||||||
},
|
},
|
||||||
@@ -752,11 +774,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
goToBookmarkTarget(cfi: string) {
|
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||||
if (this.view && cfi) {
|
if (!this.view) return;
|
||||||
this.view.goTo(cfi);
|
if (item.cfi) {
|
||||||
this.bookmarksOpen = false;
|
this.view.goTo(item.cfi);
|
||||||
|
} else if (item.page != null && item.page > 0) {
|
||||||
|
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||||
|
this.view.goTo(item.page - 1);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.bookmarksOpen = false;
|
||||||
},
|
},
|
||||||
applyTheme() {
|
applyTheme() {
|
||||||
const viewport = document.getElementById("reader-viewport")!;
|
const viewport = document.getElementById("reader-viewport")!;
|
||||||
@@ -821,27 +849,92 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyFont();
|
this.applyFont();
|
||||||
},
|
},
|
||||||
|
async refreshBookmarks() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || !this.mediaItemId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/media-items/${this.mediaItemId}/bookmarks`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const rows = await resp.json();
|
||||||
|
this.bookmarkItems = (rows as any[]).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.title ?? "",
|
||||||
|
positionLabel: r.position?.String ?? r.position ?? "",
|
||||||
|
cfi: r.cfi_position?.String ?? r.cfi_position ?? "",
|
||||||
|
page:
|
||||||
|
r.page_number != null
|
||||||
|
? (r.page_number?.Int32 ?? r.page_number)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
} catch (_e) {
|
||||||
|
/* leave the existing list on fetch failure */
|
||||||
|
}
|
||||||
|
},
|
||||||
async addBookmark() {
|
async addBookmark() {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.view) return;
|
if (!token || !this.view || !this.mediaItemId) return;
|
||||||
const location = this.view.lastLocation;
|
const location = this.view.lastLocation;
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
|
|
||||||
|
const cfi = (!this.isFixedLayout && location.cfi) || "";
|
||||||
|
const page = this.isFixedLayout
|
||||||
|
? (this.renderer?.index ?? 0) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/readers/bookmarks", {
|
const resp = await fetch(
|
||||||
method: "POST",
|
`/api/media-items/${this.mediaItemId}/bookmarks`,
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${token}`,
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: `Bookmark at ${this.progressText || "current position"}`,
|
||||||
|
position: this.isFixedLayout
|
||||||
|
? `page:${page}`
|
||||||
|
: cfi
|
||||||
|
? `cfi:${cfi}`
|
||||||
|
: "",
|
||||||
|
cfi_position: cfi,
|
||||||
|
page_number: page,
|
||||||
|
chapter_number: this.chapterNumberForProgress() || 0,
|
||||||
|
percentage: location.fraction ?? 0,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
);
|
||||||
title: `Bookmark at ${this.progressText}`,
|
if (resp.ok) await this.refreshBookmarks();
|
||||||
position: JSON.stringify(location),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore bookmark errors for now */
|
/* ignore bookmark errors for now */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async deleteBookmark(id: string) {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || !this.mediaItemId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/media-items/${this.mediaItemId}/bookmarks/${id}`,
|
||||||
|
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
if (resp.ok || resp.status === 204) {
|
||||||
|
this.bookmarkItems = this.bookmarkItems.filter((b) => b.id !== id);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
/* ignore bookmark errors for now */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chapterNumberForProgress(): number {
|
||||||
|
const tocItem = this.lastRelocateDetail?.tocItem;
|
||||||
|
if (!tocItem?.label) return 0;
|
||||||
|
const idx = this.chapterBoundaries.findIndex(
|
||||||
|
(b: any) => b.label === tocItem.label,
|
||||||
|
);
|
||||||
|
return idx === -1 ? 0 : idx;
|
||||||
|
},
|
||||||
formatProgressParts(
|
formatProgressParts(
|
||||||
fraction: number,
|
fraction: number,
|
||||||
location: { current: number; next: number; total: number },
|
location: { current: number; next: number; total: number },
|
||||||
|
|||||||
@@ -754,10 +754,11 @@
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-height: calc(100vh - 8rem);
|
max-width: calc(100vw - 1rem);
|
||||||
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.panel-container[data-side="right"] {
|
#right-sidebar .panel-container {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user