From ba95cc3e8b02c320239430d77684c48b0962f11a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 14 Aug 2026 09:05:33 -0400 Subject: [PATCH 01/22] 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. --- internal/database/db.go | 2 +- internal/database/models.go | 2 +- internal/database/querier.go | 2 +- internal/database/queries.sql.go | 4 +- internal/database/queries/queries.sql | 2 +- internal/handlers/media.go | 165 +++++++++++++++ internal/router/media.go | 6 + templates/reader.templ | 138 ++++++------- templates/reader_templ.go | 279 +++++++++----------------- web/src/reader/reader.ts | 139 ++++++++++--- web/static/input.css | 5 +- 11 files changed, 449 insertions(+), 295 deletions(-) diff --git a/internal/database/db.go b/internal/database/db.go index 486aa36..bdf4241 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.1 +// sqlc v1.30.0 package database diff --git a/internal/database/models.go b/internal/database/models.go index 0e7affe..1d937f6 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.1 +// sqlc v1.30.0 package database diff --git a/internal/database/querier.go b/internal/database/querier.go index ed1ffc7..da151e5 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.1 +// sqlc v1.30.0 package database diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2de12a2..f2082a3 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.1 +// sqlc v1.30.0 // source: queries.sql package database @@ -11274,7 +11274,7 @@ SET title = $2, notes = $3, position = $4, - updated_at = NOW() + last_modified_at = NOW() 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 ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9058c4c..bc1a513 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2481,7 +2481,7 @@ SET title = $2, notes = $3, position = $4, - updated_at = NOW() + last_modified_at = NOW() WHERE id = $1 AND user_id = $5 RETURNING *; diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 6618b09..42a0f3d 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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 diff --git a/internal/router/media.go b/internal/router/media.go index e5ac414..bbaf03c 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -41,6 +41,12 @@ func registerMediaRoutes(cfg *Config) { protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight) 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.POST("/media-items", cfg.MediaHandler.CreateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) diff --git a/templates/reader.templ b/templates/reader.templ index 75ad6b3..3beb7f7 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -5,7 +5,7 @@ import ( "fmt" ) -func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { +func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string { config := map[string]interface{}{ "mediaItemId": metadata.MediaItemID, "fileUrl": metadata.FileURL, @@ -27,6 +27,23 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { 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) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } @@ -47,36 +64,32 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm @ReaderChrome(user, metadata, progress) - -
- - - - @@ -211,14 +224,6 @@ templ ReaderSettingsPanel() {

Display

-

Reading Theme

Typography

Navigation

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

βš™οΈ Settings

Display

Reading Theme

Typography

Navigation

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -279,9 +302,9 @@ func ReaderTOCPanel(_ ReaderMetadata) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

πŸ“– Table of Contents

") @@ -292,7 +315,7 @@ func ReaderTOCPanel(_ ReaderMetadata) templ.Component { }) } -func ReaderNavigatorPanel() templ.Component { +func ReaderBookmarksPanel() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -308,134 +331,12 @@ func ReaderNavigatorPanel() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

πŸ—ΊοΈ Navigator

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var13 := templ.GetChildren(ctx) - if templ_7745c5c3_Var13 == nil { - templ_7745c5c3_Var13 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

πŸ”– Bookmarks

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(bookmarks) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

No bookmarks yet

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -func DictionaryPopup() templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var17 := templ.GetChildren(ctx) - if templ_7745c5c3_Var17 == nil { - templ_7745c5c3_Var17 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

πŸ”– Bookmarks

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 1f1d990..1e2bde6 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -382,7 +382,13 @@ document.addEventListener("alpine:init", () => { tocOpen: false, settingsOpen: false, bookmarksOpen: false, - navigatorOpen: false, + bookmarkItems: [] as { + id: string; + title: string; + positionLabel: string; + cfi: string; + page: number | null; + }[], tocItems: [] as any[], mediaItemId: "" as string, saveTimeout: null as ReturnType | null, @@ -446,8 +452,16 @@ document.addEventListener("alpine:init", () => { savedCfi?: string; savedPage?: number; savedTotalPages?: number; + bookmarks?: { + id: string; + title: string; + positionLabel: string; + cfi: string; + page: number | null; + }[]; }) { this.mediaItemId = config.mediaItemId; + this.bookmarkItems = config.bookmarks ?? []; this.settings = await loadSettings(); if (this.settings) { this.progressMode = this.settings.progress_mode || "pages"; @@ -499,7 +513,7 @@ document.addEventListener("alpine:init", () => { this.renderer.setStyles?.(this.buildCSS()); } this.view.addEventListener("load", (e: any) => { - const { doc, index } = e.detail; + const { doc } = e.detail; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; @@ -587,8 +601,19 @@ document.addEventListener("alpine:init", () => { const vp = document.getElementById("reader-viewport"); if (!top || !bot || !vp) return; const margin = 6; - vp.style.setProperty("top", `${top.offsetHeight + margin}px`); - vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`); + const topInset = `${top.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() { const top = document.getElementById("reader-topbar"); @@ -639,7 +664,7 @@ document.addEventListener("alpine:init", () => { if (tocItem) { if (tocItem.label) { const boundaryIdx = this.chapterBoundaries.findIndex( - (b: any) => b.tocItem === tocItem, + (b: any) => b.label === tocItem.label, ); if (boundaryIdx !== -1) { body.chapter = boundaryIdx; @@ -730,9 +755,6 @@ document.addEventListener("alpine:init", () => { toggleBookmarks() { this.bookmarksOpen = !this.bookmarksOpen; }, - toggleNavigator() { - this.navigatorOpen = !this.navigatorOpen; - }, toggleWindowShade(panelEl: HTMLElement) { panelEl.classList.toggle("panel-collapsed"); }, @@ -752,11 +774,17 @@ document.addEventListener("alpine:init", () => { this.tocOpen = false; } }, - goToBookmarkTarget(cfi: string) { - if (this.view && cfi) { - this.view.goTo(cfi); - this.bookmarksOpen = false; + goToBookmark(item: { cfi: string; page: number | null }) { + if (!this.view) return; + if (item.cfi) { + 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() { const viewport = document.getElementById("reader-viewport")!; @@ -821,27 +849,92 @@ document.addEventListener("alpine:init", () => { this.applyTheme(); 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() { const token = getToken(); - if (!token || !this.view) return; + if (!token || !this.view || !this.mediaItemId) return; const location = this.view.lastLocation; if (!location) return; + + const cfi = (!this.isFixedLayout && location.cfi) || ""; + const page = this.isFixedLayout + ? (this.renderer?.index ?? 0) + 1 + : 0; + try { - await fetch("/readers/bookmarks", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + const resp = await fetch( + `/api/media-items/${this.mediaItemId}/bookmarks`, + { + method: "POST", + 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}`, - position: JSON.stringify(location), - }), - }); + ); + if (resp.ok) await this.refreshBookmarks(); } catch (_e) { /* 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( fraction: number, location: { current: number; next: number; total: number }, diff --git a/web/static/input.css b/web/static/input.css index 1e08b52..e82dd3e 100644 --- a/web/static/input.css +++ b/web/static/input.css @@ -754,10 +754,11 @@ background-color: var(--bg-secondary); border-right: 1px solid var(--border); width: 320px; - max-height: calc(100vh - 8rem); + max-width: calc(100vw - 1rem); + max-height: 100%; overflow-y: auto; } - .panel-container[data-side="right"] { + #right-sidebar .panel-container { border-right: none; border-left: 1px solid var(--border); } From 612f88868384a26c2284f111e58b771b0b70365f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 14 Aug 2026 14:59:51 -0400 Subject: [PATCH 02/22] feat(reader): immersive chrome, slide-over drawers, tri-state PDF pointer mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the reader redesign: - Reading surface is edge-to-edge; top/bottom bars overlay translucently (backdrop-blur) instead of reserving insets, killing the inset-coordination bug class entirely. Chrome auto-hides after 2.5s of pointer inactivity (chrome_behavior setting finally wired: auto-hide / always-visible; legacy values map to auto-hide). Pointer activity inside page iframes keeps it awake; Esc toggles. - TOC / Settings / Bookmarks become slide-over drawers with a scrim (z-50, full-height, safe-area aware), replacing the dockable-panel system and its window-shade headers. Only one drawer opens at a time; Esc or scrim click closes. - Bottom bar is contextual: reflowable keeps nav/slider/progress/TOC; fixed-layout row adds Fit Page/Width select, zoom cluster, magnifier (now shows active state), Double Page Spread toggle, and a Smart | Pan | Text segmented control replacing the cryptic two-state icon. Smart = text-aware drag; Text = selection-only (manual smart-detect off); Pan = force pan. Choice persists via pdf_interaction_mode (new setting + foliate 29bc958 'text' mode). - Settings drawer: Behavior (chrome, progress mode), Appearance with 18 Kindle-style theme swatches (single source of truth from THEME_COLORS), Typography, Layout β€” each scoped by format. - Keyboard: t/s/b open TOC/settings/bookmark, Esc closes drawers before toggling chrome, shortcuts skip form inputs; both slider rows tracked correctly (no duplicate-ID lookups). - Topbar: Back, title, add-bookmark, bookmarks drawer, Aa settings; chrome follows user theme. --- internal/services/reader.go | 25 +- package.json | 2 +- templates/reader.templ | 580 +++++++++++++++-------------- templates/reader_templ.go | 235 ++++++++---- web/src/reader/reader.ts | 198 +++++++--- web/src/reader/settings-manager.ts | 1 + web/src/types/reader.d.ts | 3 +- web/static/input.css | 79 +++- 8 files changed, 678 insertions(+), 445 deletions(-) diff --git a/internal/services/reader.go b/internal/services/reader.go index 69b2919..813328c 100644 --- a/internal/services/reader.go +++ b/internal/services/reader.go @@ -392,18 +392,19 @@ func (s *ReaderService) UpdateSettings( func (s *ReaderService) getDefaultSettings() map[string]interface{} { return map[string]interface{}{ - "chrome_behavior": "auto-hide", - "progress_mode": "pages", - "chrome_theme": "tokyo-night", - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 16, - "line_height": 1.6, - "margin_width": 20, - "tap_zone_size": 30, - "auto_scroll": false, - "panel_zoom_enabled": true, - "double_page_spread": true, + "chrome_behavior": "auto-hide", + "progress_mode": "pages", + "chrome_theme": "tokyo-night", + "reading_theme": "dark", + "reading_font": "literata", + "font_size": 16, + "line_height": 1.6, + "margin_width": 20, + "tap_zone_size": 30, + "auto_scroll": false, + "panel_zoom_enabled": true, + "double_page_spread": true, + "pdf_interaction_mode": "select", // Dockable panel defaults "panel_layout": map[string]interface{}{ diff --git a/package.json b/package.json index 0010e72..5e02b79 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "npm run build:ts:dev && npm run build:css" }, "dependencies": { - "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#d4d87a9", + "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#29bc958", "alpinejs": "^3.15.8", "chart.js": "^4.5.1", "highlight.js": "^11.11.1", diff --git a/templates/reader.templ b/templates/reader.templ index 3beb7f7..1b7d8f0 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -67,59 +67,102 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm x-init={ readerInitExpr(metadata, progress, bookmarks) } class={ "theme-" + user.Theme + " h-screen overflow-hidden" } > - @ReaderChrome(user, metadata, progress) - -
- -