From 89665fb2bca0de2753f3b51c5f5974670ad8620a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 15 Oct 2026 19:33:18 -0400 Subject: [PATCH] feat(annotations + player): bookmark rows in the banner, stacked volume panel, badge parity for comics/manga MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotations banner: audio/text bookmarks join notes and highlights — third row kind (bookmark glyph indicator, H:MM:SS / Page N / Chapter N friendly labels), edit-note/delete actions branch by kind, and the banner's export now covers all annotations. The archived ledger (ListDeletedAnnotationsForBook) already tombstoned bookmarks equally and Restore/Purge take annotation_type=bookmark — verified live: delete → restore brings the row back with its note intact. Volume: the dragger sits BELOW the mute icon in the panel (stacked flyout shape) with the horizontal slider from the last round. Badges: the real fix for comics/manga — format_group only knows 'comic_archive' (manga_type is unreliable across sources), so the comic/manga split is resolved from the LIBRARY type server-side. handlers.MediaBadgeKind + BookInfo.Badge at every construction site (dashboard SSR + sections API, collections, series, and the bookshelf search grid — which was also missing FormatGroup entirely, so no card ever badged there). Both renderers (templ + dashboard.ts) switch on the resolved badge. Verified: All-Books grid renders 2 Comic + 3 Manga badges, dashboard the audiobook one. Infra: header.templ main.js src gains ?v= — browsers keyed an intermediate bundle by URL and served it across rebuilds (the stale 'Add note' the user saw); bump the stamp when JS stale-shadows. --- internal/handlers/collections.go | 41 ++++++++++ internal/handlers/dashboard.go | 6 +- internal/handlers/series.go | 5 ++ internal/router/frontend.go | 9 ++- internal/router/search.go | 2 + templates/annotations_banner.templ | 16 ++-- templates/annotations_banner_templ.go | 4 +- templates/audio_reader.templ | 2 +- templates/audio_reader_templ.go | 2 +- templates/dashboard.templ | 7 +- templates/dashboard_templ.go | 103 +++++++++++++------------- templates/header.templ | 4 +- templates/header_templ.go | 6 +- templates/utils.go | 22 +++++- web/src/annotations-banner.ts | 53 ++++++++++++- web/src/audio.ts | 9 ++- web/src/book-detail.ts | 50 +++++++++++-- web/src/dashboard.ts | 23 ++++-- web/src/types/api.d.ts | 1 + 19 files changed, 273 insertions(+), 92 deletions(-) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index dee8431f..9523b8e9 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -5,6 +5,7 @@ import ( "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" + "context" "encoding/json" "errors" "fmt" @@ -77,6 +78,40 @@ type BookInfo struct { HasConflict bool `json:"has_conflict"` // Media-type identifier for card badges/hover icons (A2c) FormatGroup string `json:"format_group"` + // Resolved cover-badge glyph: "audiobook" | "comic" | "manga" | "". + // comic vs manga rides the LIBRARY type (format_group only knows + // comic_archive — manga_type is unreliable across sources). + Badge string `json:"badge"` +} + +// LibraryTypeMap builds libraryID→library-type-name for badge resolution +// (one small query per request; libraries are few). +func LibraryTypeMap(ctx context.Context, db *database.Queries) map[string]string { + m := map[string]string{} + libs, err := db.ListLibraries(ctx) + if err != nil { + return m + } + for _, l := range libs { + if l.ID.Valid { + m[uuid.UUID(l.ID.Bytes).String()] = l.TypeName + } + } + return m +} + +// MediaBadgeKind resolves the badge glyph per media type from the row's +// format group and its library's type name. +func MediaBadgeKind(formatGroup, libraryTypeName string) string { + switch { + case formatGroup == "audiobook": + return "audiobook" + case formatGroup == "comic_archive" && libraryTypeName == "manga": + return "manga" + case formatGroup == "comic_archive": + return "comic" + } + return "" } type SectionData struct { @@ -280,12 +315,14 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { bookList = make([]BookInfo, 0, len(matches)) for _, item := range matches { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + libTypes := LibraryTypeMap(c.Request().Context(), h.db) bookList = append(bookList, BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), FormatGroup: item.FormatGroup, + Badge: MediaBadgeKind(item.FormatGroup, libTypes[uuid.UUID(item.LibraryID.Bytes).String()]), }) } } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { @@ -328,6 +365,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { validItems = append(validItems, item) } } + libTypes := LibraryTypeMap(c.Request().Context(), h.db) bookCards := make([]BookInfo, len(validItems)) for i, item := range validItems { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) @@ -337,6 +375,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), FormatGroup: item.FormatGroup, + Badge: MediaBadgeKind(item.FormatGroup, libTypes[uuid.UUID(item.LibraryID.Bytes).String()]), } } bookList = bookCards @@ -348,12 +387,14 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { } bookList = make([]BookInfo, 0, len(books)) for _, book := range books { + libTypes := LibraryTypeMap(c.Request().Context(), h.db) bookList = append(bookList, BookInfo{ MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), Title: book.Title, Author: textToString(book.Author), CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath), FormatGroup: book.FormatGroup, + Badge: MediaBadgeKind(book.FormatGroup, libTypes[uuid.UUID(book.LibraryID.Bytes).String()]), }) } } diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index f361cb2f..81610b2f 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -73,8 +73,9 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error { } } } + libTypes := LibraryTypeMap(c.Request().Context(), h.db) - sectionData := BuildSections(sections, libraryID, libraryType) + sectionData := BuildSections(sections, libraryID, libraryType, libTypes) sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) @@ -182,7 +183,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error { // active library is audiobooks, system-collection titles/descriptions // swap to the listening wording at display time — the stored names stay // generic so restores and settings keep working across library types. -func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string) []SectionData { +func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string, libTypes map[string]string) []SectionData { var result []SectionData audioScope := libraryType == "audiobooks" @@ -208,6 +209,7 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), FormatGroup: item.FormatGroup, + Badge: MediaBadgeKind(item.FormatGroup, libTypes[uuid.UUID(item.LibraryID.Bytes).String()]), } } diff --git a/internal/handlers/series.go b/internal/handlers/series.go index 0b820480..18a36dec 100644 --- a/internal/handlers/series.go +++ b/internal/handlers/series.go @@ -15,11 +15,13 @@ import ( type SeriesHandler struct { seriesService *services.SeriesService + db *database.Queries } func NewSeriesHandler(db *database.Queries) *SeriesHandler { return &SeriesHandler{ seriesService: services.NewSeriesService(db), + db: db, } } @@ -154,12 +156,15 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error { bookCards := make([]BookInfo, 0, len(books)) for _, item := range books { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + libTypes := LibraryTypeMap(c.Request().Context(), h.db) + _ = libTypes bookCards = append(bookCards, BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), FormatGroup: item.FormatGroup, + Badge: MediaBadgeKind(item.FormatGroup, libTypes[uuid.UUID(item.LibraryID.Bytes).String()]), }) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 784a2abb..e85bc709 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -482,9 +482,14 @@ func registerFrontendRoutes(cfg *Config) { } } + libTypes := map[string]string{} + for _, ld := range libRes.Libraries { + libTypes[ld.ID] = ld.TypeValue + } + userPgID := pgtype.UUID{Bytes: userUUID, Valid: true} - sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType)) - allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType) + sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType, libTypes)) + allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType, libTypes) // "This week" widget: the user's rolling 7-day reading pulse for // the CURRENTLY SELECTED library (the same resolution the sections diff --git a/internal/router/search.go b/internal/router/search.go index d3cd95e5..e40d3ad5 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -110,6 +110,8 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { Title: book.Title, Author: textToString(book.Author), CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath), + FormatGroup: book.FormatGroup, + Badge: handlers.MediaBadgeKind(book.FormatGroup, book.LibraryTypeName), } } // Stamp active conflict flags so cards route the play action correctly diff --git a/templates/annotations_banner.templ b/templates/annotations_banner.templ index a8d19ae8..736f99d2 100644 --- a/templates/annotations_banner.templ +++ b/templates/annotations_banner.templ @@ -29,7 +29,7 @@ templ AnnotationsBanner(book handlers.MediaDetail) { aria-label="Annotations" >
-

Notes & Highlights

+

Notes, Highlights & Bookmarks

Loading…

@@ -64,6 +64,12 @@ templ AnnotationsBanner(book handlers.MediaDetail) { + diff --git a/templates/annotations_banner_templ.go b/templates/annotations_banner_templ.go index 519789fd..8bbd0103 100644 --- a/templates/annotations_banner_templ.go +++ b/templates/annotations_banner_templ.go @@ -42,7 +42,7 @@ func AnnotationsBanner(book handlers.MediaDetail) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Notes & Highlights

Loading…

Markdown supported

Export annotations

Notes and highlights, as markdown files.

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

Loading…

Markdown supported

Export annotations

Notes and highlights, as markdown files.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index a6996cb8..19d8ee17 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -314,7 +314,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
0:00 0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

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

—

0:00 0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/dashboard.templ b/templates/dashboard.templ index 75bca395..da6e0565 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -3,7 +3,6 @@ package templates import ( "bookhoard/internal/handlers" "fmt" - "strings" ) templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) { @@ -133,13 +132,13 @@ templ BookCard(item handlers.BookInfo) { } - if MediaBadgeIcon(item.FormatGroup) != "" { + if MediaBadgeIcon(item.Badge) != "" { - @Icon(MediaBadgeIcon(item.FormatGroup), "h-4 w-4") + @Icon(MediaBadgeIcon(item.Badge), "h-4 w-4") } diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index d20a2a4a..1b232eff 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -11,7 +11,6 @@ import templruntime "github.com/a-h/templ/runtime" import ( "bookhoard/internal/handlers" "fmt" - "strings" ) func Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) templ.Component { @@ -123,7 +122,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 46, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 45, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -136,7 +135,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 47, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 46, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -149,7 +148,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 51, Col: 130} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 50, Col: 130} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -162,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 53, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 52, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -180,7 +179,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 55, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 54, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -203,7 +202,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var8 templ.SafeURL templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 61, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 60, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -229,7 +228,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 74, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 73, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -250,7 +249,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 82, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -268,7 +267,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 87, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 86, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -308,7 +307,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 101, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 100, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { @@ -358,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var14 templ.SafeURL templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 114, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 113, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -376,7 +375,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 121, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 120, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -389,7 +388,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 122, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 121, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -407,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 130, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 129, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -422,15 +421,15 @@ func BookCard(item handlers.BookInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if MediaBadgeIcon(item.FormatGroup) != "" { + if MediaBadgeIcon(item.Badge) != "" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " - + + } diff --git a/templates/header_templ.go b/templates/header_templ.go index 4c612839..ab2e389d 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -631,7 +631,7 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -676,7 +676,7 @@ func SidebarUserMenu(user User) templ.Component { var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 230, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 232, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { @@ -760,7 +760,7 @@ func SidebarSignIn(currentPath string) templ.Component { var templ_7745c5c3_Var39 string templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 278, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 280, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) if templ_7745c5c3_Err != nil { diff --git a/templates/utils.go b/templates/utils.go index 7ae9cf36..41048522 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -62,11 +62,12 @@ func isUserVisible(userID string, visibility []UserVisibilityData) bool { // TotalMediaCount sums the MediaCount across the given libraries, // used to display the total next to the "All Libraries" option. -// MediaBadgeIcon selects the cover-corner badge glyph per media group -// (A2c): the per-type identifier on mixed grids. Books stay unbadged — +// MediaBadgeIcon selects the cover-corner badge glyph from the RESOLVED +// badge kind (handlers.MediaBadgeKind — format_group + library type; +// comic vs manga rides the typed-library doctrine). Books stay unbadged: // the shelf's default reads as a book without a stamp. -func MediaBadgeIcon(formatGroup string) string { - switch formatGroup { +func MediaBadgeIcon(badge string) string { + switch badge { case "audiobook": return "headphones" case "comic": @@ -382,3 +383,16 @@ func fmtSecondsHMS(seconds float64) string { } return fmt.Sprintf("%d:%02d", m, sec) } + +// badgeLabel is the hover text for a cover badge. +func badgeLabel(badge string) string { + switch badge { + case "audiobook": + return "Audiobook" + case "comic": + return "Comic" + case "manga": + return "Manga" + } + return "" +} diff --git a/web/src/annotations-banner.ts b/web/src/annotations-banner.ts index cb211fac..60c26b82 100644 --- a/web/src/annotations-banner.ts +++ b/web/src/annotations-banner.ts @@ -13,8 +13,8 @@ import { renderNoteHtml, stripNote } from "./markdown"; interface AnnotationRow { /** stable key for x-for */ key: string; - /** note | highlight */ - kind: "note" | "highlight"; + /** note | highlight | bookmark */ + kind: "note" | "highlight" | "bookmark"; /** human location label (Section N / Page N / NN% / source words) */ label: string; /** compact preview line: stripped markdown (note) or capped selection */ @@ -83,9 +83,32 @@ function pdfEnvelopePage(cfi: string): number | null { return null; } +/** Bookmark position labels: audio: renders H:MM:SS; page:N / + * comic:page:N renders Page N; chapter:N renders Chapter N (0-based in + * storage); else the bookmark title's own words. */ +function bookmarkLabel(position: string | null | undefined, title: string): string { + const raw = (position ?? "").trim(); + if (raw.startsWith("audio:")) { + const sec = parseInt(raw.slice(6), 10); + if (Number.isFinite(sec) && sec >= 0) { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const sec2 = sec % 60; + const two = (n: number) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${two(m)}:${two(sec2)}` : `${m}:${two(sec2)}`; + } + } + const pageM = /^(?:comic:)?page:(\d+)$/.exec(raw); + if (pageM) return `Page ${pageM[1]}`; + const chapM = /^chapter:(\d+)$/.exec(raw); + if (chapM) return `Chapter ${Number(chapM[1]) + 1}`; + return title || "Bookmark"; +} + function buildAnnotationRows( notes: any[], highlights: any[], + bookmarks: any[] = [], ): { rows: AnnotationRow[]; sources: AnnotationSource[] } { const rows: AnnotationRow[] = []; const sources: AnnotationSource[] = []; @@ -161,6 +184,32 @@ function buildAnnotationRows( sources.push({ row, source: note, highlightText: selection }); } + for (const b of bookmarks) { + if (b.deleted) continue; + const notesrc = String(b.notes ?? ""); + const title = String(b.title ?? ""); + const label = bookmarkLabel(b.position, title); + const loc = String(b.position ?? ""); + const row: AnnotationRow = { + key: `bookmark-${b.id}`, + kind: "bookmark", + label, + preview: title || label, + richSelection: "", + richNote: renderNoteHtml(notesrc), + loc, + color: "var(--accent)", + hasNote: notesrc.trim().length > 0, + expanded: false, + }; + rows.push(row); + sources.push({ + row, + source: notesrc, + highlightText: title || label, + }); + } + return { rows, sources }; } diff --git a/web/src/audio.ts b/web/src/audio.ts index 49cdb415..b0bad562 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -217,13 +217,20 @@ Alpine.data("audioReader", () => ({ const p = await progress.json(); const chapter = Number(p.chapter ?? 0); const offset = Number(p.audio_offset_seconds ?? 0); - if (Number.isFinite(p.percentage) && this.bookDuration > 0) { + // Banner/deep-link jumps (?loc=audio:) win over the + // stored position: the user explicitly asked for this spot. + const locParam = new URLSearchParams(window.location.search).get("loc") || ""; + if (locParam.startsWith("audio:")) { + const at = Number(locParam.slice("audio:".length)); + if (Number.isFinite(at)) this.seekTo(at); + } else if (Number.isFinite(p.percentage) && this.bookDuration > 0) { const at = Number(p.percentage) * this.bookDuration; this.seekTo(at); } else if (chapter > 0 && this.chapters[chapter]?.start != null) { this.seekTo(this.chapters[chapter].start + offset); } this.currentChapterIdx = Math.max(0, chapter); + this.tickChapter(); } } catch { // Offline start: still playable; sync resumes when reachable. diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index f0a6783d..3686d5d4 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -230,29 +230,37 @@ Alpine.data("bookDetail", () => { const mediaId = getMediaId(); this.annotationsBannerLoading = true; try { - const [noteResp, hlResp] = await Promise.all([ + const [noteResp, hlResp, bmResp] = await Promise.all([ fetch(`/api/media-items/${mediaId}/notes`, { headers: { Authorization: getAuthHeader() }, }), fetch(`/api/media-items/${mediaId}/highlights`, { headers: { Authorization: getAuthHeader() }, }), + fetch(`/api/media-items/${mediaId}/bookmarks`, { + headers: { Authorization: getAuthHeader() }, + }), ]); // A partial failure must not read as "no annotations": a failed // endpoint used to be published as an empty list, silently wiping // that kind from the banner while the other kind stayed (found // auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c). // Keep the previous rows; the next banner open retries. - if (!noteResp.ok || !hlResp.ok) { + if (!noteResp.ok || !hlResp.ok || !bmResp.ok) { console.warn( `banner annotation load failed ` + - `(notes ${noteResp.status}, highlights ${hlResp.status}) — keeping previous`, + `(notes ${noteResp.status}, highlights ${hlResp.status}, bookmarks ${bmResp.status}) — keeping previous`, ); return; } const notes = await noteResp.json(); const highlights = await hlResp.json(); - const built = buildAnnotationRows(notes, highlights); + const bookmarks = await bmResp.json(); + const built = buildAnnotationRows( + notes, + highlights, + Array.isArray(bookmarks) ? bookmarks : [], + ); this.bannerRows = built.rows; this.bannerSources = built.sources; this.bannerLoaded = true; @@ -282,7 +290,22 @@ Alpine.data("bookDetail", () => { if (!row || (!content && row.kind === "note")) return; const mediaId = getMediaId(); try { - if (row.kind === "note") { + if (row.kind === "bookmark") { + // Full-replace PUT: title + position ride along from the row's + // stored values; the note is the editable part. + const bmId = row.key.slice("bookmark-".length); + const src = this.bannerSources.find(s => s.row.key === row.key); + const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, { + method: "PUT", + headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" }, + body: JSON.stringify({ + title: String(src?.highlightText || "Bookmark"), + notes: content, + position: row.loc || "", + }), + }); + if (!resp.ok) throw new Error(String(resp.status)); + } else if (row.kind === "note") { const noteId = row.key.slice("note-".length); // The stored position rides along: the server's PUT hard-sets the // position column. @@ -352,6 +375,23 @@ Alpine.data("bookDetail", () => { showToast("Could not delete the note", "error"); } }, + async deleteBannerBookmark(row: AnnotationRow) { + if (row.kind !== "bookmark") return; + const mediaId = getMediaId(); + const bmId = row.key.slice("bookmark-".length); + try { + const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, { + method: "DELETE", + headers: { Authorization: getAuthHeader() }, + }); + if (!resp.ok) throw new Error(String(resp.status)); + // Tombstoned server-side: restorable from the Deleted modal. + showToast("Bookmark deleted — restorable from Deleted", "success"); + await this.fetchBannerAnnotations(); + } catch (_e) { + showToast("Could not delete the bookmark", "error"); + } + }, async deleteBannerHighlight(row: AnnotationRow) { if (row.kind !== "highlight") return; const mediaId = getMediaId(); diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index 52830b51..c671f925 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -302,11 +302,20 @@ const MANGA_ICON = '' + '' + ""; -// Media-type identifier for mixed grids: '' for books (unbadged default) -function badgeIconFor(formatGroup: string): string { - if (formatGroup === "audiobook") return HEADPHONES_ICON; - if (formatGroup === "comic") return COMIC_ICON; - if (formatGroup === "manga") return MANGA_ICON; +// Media-type identifier for mixed grids, from the server-resolved badge +// (format_group + library type — comic vs manga is a LIBRARY distinction): +// '' for books (unbadged default) +function badgeIconFor(badge: string): string { + if (badge === "audiobook") return HEADPHONES_ICON; + if (badge === "comic") return COMIC_ICON; + if (badge === "manga") return MANGA_ICON; + return ""; +} + +function badgeTitleFor(badge: string): string { + if (badge === "audiobook") return "Audiobook"; + if (badge === "comic") return "Comic"; + if (badge === "manga") return "Manga"; return ""; } @@ -328,7 +337,7 @@ function renderBookCard(book: BookInfo): string { ? "Listen" : "Read"; const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON; - const badgeIcon = badgeIconFor(book.format_group || ""); + const badgeIcon = badgeIconFor(book.badge || ""); return `
@@ -340,7 +349,7 @@ function renderBookCard(book: BookInfo): string { class="w-full h-full object-cover" loading="lazy" onerror="this.src='/static/placeholder-book.svg'"> - ${badgeIcon ? `${badgeIcon}` : ""} + ${badgeIcon ? `${badgeIcon}` : ""}

diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts index fbb308fa..d2b178ea 100644 --- a/web/src/types/api.d.ts +++ b/web/src/types/api.d.ts @@ -103,6 +103,7 @@ interface BookInfo { cover_image_path: string; has_conflict: boolean; format_group: string; + badge: string; } // Dashboard type definitions