From fe5ab9e5f8d9991377666a18a58bc29f3634fa94 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 12:00:49 -0400 Subject: [PATCH] feat(ui): hide missing books immediately and mark offline libraries Two visibility fixes so the UI reflects the shelf's real state on the next scan instead of only after the archive gate: - Missing items disappear at once: the user-facing filters already hid archived rows; the same listings now also require missing_scan_count = 0. A deleted or moved-then-not-yet-repointed file vanishes from the UI immediately, while purge timing stays gated on archived_at plus the retention window - grace protects data, not visibility. Restored automatically when the file returns. - Steam Deck SD-card model for unmounted storage: resolveLibrary stats each library's folder roots and flags libraries with no live folder as Offline (LibraryData gains the field, computed at request time so mounts/unmounts react instantly). The bookshelf shows an empty shelf plus a 'storage is not connected' notice for an offline selected library, and both the shared LibrarySwitcher and the bookshelf's inline select label offline libraries with their true holding counts. Nothing is marked or purged while offline. - TotalMediaCount skips offline libraries, so the 'All Books/Libraries' totals match what is actually visible. Verified live: renaming uploads/Manga away produced the notice, an empty shelf, and the offline dropdown label with a corrected total; renaming it back restored all 38 cards with zero dirty rows. --- internal/router/frontend.go | 13 ++ internal/router/helpers.go | 29 +++- templates/bookshelf.templ | 12 +- templates/bookshelf_templ.go | 220 ++++++++++++++++++++-------- templates/library_switcher.templ | 14 +- templates/library_switcher_templ.go | 178 ++++++++++++++++------ templates/types.go | 26 +++- templates/utils.go | 6 + 8 files changed, 373 insertions(+), 125 deletions(-) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index b89306a..017a61f 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "bookhoard/internal/config" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" @@ -295,6 +296,18 @@ func registerFrontendRoutes(cfg *Config) { libraryID := libRes.LibraryID libData := libRes.Libraries + // Steam Deck behavior: an offline library (all folders missing, e.g. + // an unmounted external drive) shows an empty shelf with a notice + // instead of querying items. Nothing is marked or purged. + if !libRes.IsAll { + for _, l := range libData { + if l.ID == libraryID && l.Offline { + errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached." + break + } + } + } + // Fetch saved filters for SSR userUUID, _ := uuid.Parse(user.ID) var savedFilters []database.SavedFilters diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 88441bf..c3b610e 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -4,6 +4,7 @@ import ( "context" "log" "net/url" + "os" "time" "bookhoard/internal/database" @@ -94,11 +95,11 @@ const selectedLibraryCookie = "selectedLibrary" const allLibrariesSentinel = "__all__" type LibraryResolution struct { - LibraryID string - IsAll bool - LibUUID pgtype.UUID - Libraries []templates.LibraryData - FirstID string + LibraryID string + IsAll bool + LibUUID pgtype.UUID + Libraries []templates.LibraryData + FirstID string } func getText(t pgtype.Text) string { @@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu res.Libraries = make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + // Steam Deck SD-card behavior: a library whose folders are all + // missing (unmounted drive) renders offline - hidden contents, no + // marking, no purging - and returns when the storage does. + offline := false + folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID) + if folderErr != nil || len(folders) == 0 { + offline = true + } else { + anyFolderExists := false + for _, folder := range folders { + if _, statErr := os.Stat(folder.FolderPath); statErr == nil { + anyFolderExists = true + break + } + } + offline = !anyFolderExists + } res.Libraries[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: getText(lib.Description), TypeName: lib.TypeName, MediaCount: countMap[libUUID.String()], + Offline: offline, } } diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 498f64f..46c4067 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -148,14 +148,20 @@ templ BookShelf( } else { } - for _, lib := range libraries { + for _, lib := range libraries { + if lib.Offline { if lib.ID == currentLibraryID { - + } else { - + } + } else if lib.ID == currentLibraryID { + + } else { + } } + }
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 5d08e46..1a2c378 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_templ.go @@ -203,98 +203,188 @@ func BookShelf( } } for _, lib := range libraries { - if lib.ID == currentLibraryID { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else if lib.ID == currentLibraryID { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
= 2) fetchAuthorValues($el)\">
= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\">
= 2) fetchSeriesValues($el)\">
= 2) fetchLanguageValues($el)\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -303,53 +393,53 @@ func BookShelf( return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 275, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if count > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Prev Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 293, Col: 32} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

Save Filter

Save Filter

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/library_switcher.templ b/templates/library_switcher.templ index 0acfc38..bff69f7 100644 --- a/templates/library_switcher.templ +++ b/templates/library_switcher.templ @@ -13,14 +13,20 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions .. name="library_id" class="input w-auto py-1.5 pr-8 cursor-pointer" > - - for _, lib := range libData { + + for _, lib := range libData { + if lib.Offline { if lib.ID == currentLibraryID { - + } else { - + } + } else if lib.ID == currentLibraryID { + + } else { + } + } if len(actions) > 0 { diff --git a/templates/library_switcher_templ.go b/templates/library_switcher_templ.go index 10913cb..a14bfb2 100644 --- a/templates/library_switcher_templ.go +++ b/templates/library_switcher_templ.go @@ -36,7 +36,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ... var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -47,102 +47,192 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ... return templ_7745c5c3_Err } for _, lib := range libData { - if lib.ID == currentLibraryID { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else if lib.ID == currentLibraryID { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(actions) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -152,12 +242,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ... return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -181,12 +271,12 @@ func DashboardActions() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 9bde5a7..afdda90 100644 --- a/templates/types.go +++ b/templates/types.go @@ -41,22 +41,40 @@ type LibraryData struct { TypeValue string MediaCount int64 FolderCount int + // Offline is true when none of the library's folders exist on disk + // (e.g. an unmounted external drive). Contents stay intact and reappear + // when the storage returns. + Offline bool } type FolderData struct { FolderPath string } +// ArchivedItem feeds the admin archived-items page: media hidden from +// libraries because their files vanished, with their pending fate. +type ArchivedItem struct { + ID string + Title string + Author string + LibraryName string + FilePath string + MissingScans int32 + ArchivedAt *time.Time + PurgeAt *time.Time + Status string +} + type DirEntry struct { Name string Path string } type UserVisibilityData struct { - UserID string - Username string - Email string - IsVisible bool + UserID string + Username string + Email string + IsVisible bool } type AdminStats struct { diff --git a/templates/utils.go b/templates/utils.go index 88c9add..7adf3b0 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -65,6 +65,12 @@ func isUserVisible(userID string, visibility []UserVisibilityData) bool { func TotalMediaCount(libs []LibraryData) int64 { var total int64 for _, l := range libs { + // Unmounted storage contributes nothing visible: an offline + // library's holdings stay in the database but out of the UI + // until its folders return. + if l.Offline { + continue + } total += l.MediaCount } return total