feat(annotations + player): bookmark rows in the banner, stacked volume panel, badge parity for comics/manga

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=<stamp> — 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.
This commit is contained in:
John O'Keefe committed 2026-10-15 19:33:18 -04:00
1 parent 2c714dcb60
commit 89665fb2bc
19 files changed
+273 -92

No files matched your search

+41
View File
@@ -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()]),
})
}
}
+4 -2
View File
@@ -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()]),
}
}
+5
View File
@@ -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()]),
})
}
+7 -2
View File
@@ -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
+2
View File
@@ -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