diff --git a/cmd/server/tests/admin_library_page_test.go b/cmd/server/tests/admin_library_page_test.go new file mode 100644 index 00000000..2170d8b2 --- /dev/null +++ b/cmd/server/tests/admin_library_page_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The Library Type dropdown on /admin/library (and the setup wizard, which +// shares LibraryTypeOptions) must render from the DB-backed library_types +// table. Regression: audiobooks shipped backend-complete (constant, allowed +// extensions, MIME map, schema seed row, /api/libraries/types) while the +// dropdowns hardcoded the original three types, so an audiobook library +// could not be created from the UI. +func TestAdminLibraryPageDropdownCoversAllLibraryTypes(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + setup := setupTestServer(t) + + code, html := ssrGet(t, setup.Server.URL+"/admin/library", setup.Token) + require.Equal(t, http.StatusOK, code) + + for _, typ := range []string{"ebooks", "comics", "manga", "audiobooks"} { + assert.Contains(t, html, `value="`+typ+`"`, + "library type %q missing from the create-library dropdown", typ) + } + assert.Contains(t, html, "Audiobooks") + + // Options come from the DB loop, not the deleted hardcode: exactly one + // audiobooks option per page. + assert.Equal(t, 1, strings.Count(html, `value="audiobooks"`)) +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go index e9d73033..3923ee1b 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -1143,7 +1143,8 @@ func registerFrontendRoutes(cfg *Config) { var buf bytes.Buffer archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context()) - err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf) + libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg) + err = templates.AdminLibrary(user, libData, userData, archivedCount, libraryTypes).Render(c.Request().Context(), &buf) if err != nil { return err } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 90a494a9..9d7cfb4a 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -405,3 +405,24 @@ func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libUUID, libUUID, libraryID != "", visibleIDs) } + +// libraryTypeOptionsFor loads the library_types rows for the create-library +// dropdowns (admin page + setup wizard), ordered for display. On DB failure +// it falls back to the built-in type names so the form stays usable — +// CreateLibrary resolves the name against the DB, so this is never +// authoritative. +func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes { + types, err := cfg.LibraryService.GetLibraryTypes(ctx) + if err != nil { + log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err) + for _, name := range []string{ + services.LibraryTypeEbooks, + services.LibraryTypeComics, + services.LibraryTypeManga, + services.LibraryTypeAudiobooks, + } { + types = append(types, database.LibraryTypes{Name: name}) + } + } + return templates.OrderedLibraryTypes(types) +} diff --git a/internal/router/setup.go b/internal/router/setup.go index cd9f71ad..61accfc6 100644 --- a/internal/router/setup.go +++ b/internal/router/setup.go @@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) { return c.Redirect(http.StatusFound, "/") } var buf bytes.Buffer - if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil { + libraryTypes := libraryTypeOptionsFor(c.Request().Context(), cfg) + if err := templates.Setup(libraryTypes).Render(c.Request().Context(), &buf); err != nil { log.Printf("Failed to render setup template: %v", err) return c.HTML(http.StatusInternalServerError, "Failed to render setup page") } diff --git a/templates/admin_library.templ b/templates/admin_library.templ index 63f50839..b522407b 100644 --- a/templates/admin_library.templ +++ b/templates/admin_library.templ @@ -1,6 +1,8 @@ package templates -templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) { +import "bookhoard/internal/database" + +templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64, libraryTypes []database.LibraryTypes) { @@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
@@ -178,6 +178,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou } +// LibraryTypeOptions renders the

Edit Library

Edit Library

Browse Folders

Browse Folders

Delete Library

Delete Library

Are you sure you want to delete ?

Are you sure you want to delete ?

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -178,6 +188,66 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun }) } +// LibraryTypeOptions renders the - - + @LibraryTypeOptions(libraryTypes)
diff --git a/templates/setup_templ.go b/templates/setup_templ.go index 07511c3c..30d1f86b 100644 --- a/templates/setup_templ.go +++ b/templates/setup_templ.go @@ -8,7 +8,9 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func Setup() templ.Component { +import "bookhoard/internal/database" + +func Setup(libraryTypes []database.LibraryTypes) 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 { @@ -85,7 +87,15 @@ func Setup() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Configure Library Folders

Add the filesystem folders where your media files are stored.

No libraries created. You can add folders later from the admin panel.

Scan & Finish

Review your configuration and start the initial scan.

Server URL

Administrator

Libraries ()

Scan Progress

Initial scan complete! Your libraries are ready to use.

Scan & Finish

Review your configuration and start the initial scan.

Server URL

Administrator

Libraries ()

Scan Progress

Initial scan complete! Your libraries are ready to use.

Browse Folders

Browse Folders

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/utils.go b/templates/utils.go index 5dff1b0a..f32e7649 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -409,3 +409,61 @@ func badgeLabel(badge string) string { } return "" } + +// Library type dropdown helpers. The option list comes from the DB-backed +// library_types table (the same source the create API resolves types +// against), so a new type appears in the UI without template edits. + +// libraryTypeDisplayOrder pins the long-standing library types to their +// historical dropdown order. +var libraryTypeDisplayOrder = map[string]int{ + "ebooks": 0, + "comics": 1, + "manga": 2, + "audiobooks": 3, +} + +const libraryTypeRankUnknown = 1 << 30 + +func libraryTypeRank(name string) int { + if rank, ok := libraryTypeDisplayOrder[name]; ok { + return rank + } + return libraryTypeRankUnknown +} + +// OrderedLibraryTypes sorts library types for a create-library dropdown: +// the original types keep their UI order, any newer types follow by name. +func OrderedLibraryTypes(types []database.LibraryTypes) []database.LibraryTypes { + sorted := make([]database.LibraryTypes, len(types)) + copy(sorted, types) + sort.SliceStable(sorted, func(i, j int) bool { + ri, rj := libraryTypeRank(sorted[i].Name), libraryTypeRank(sorted[j].Name) + if ri != rj { + return ri < rj + } + return sorted[i].Name < sorted[j].Name + }) + return sorted +} + +// LibraryTypeDisplayName renders a library_types.name for the UI +// ("audiobooks" → "Audiobooks"); unknown names just get their first letter +// capitalized so a future type still reads sensibly. +func LibraryTypeDisplayName(name string) string { + switch name { + case "ebooks": + return "Ebooks" + case "comics": + return "Comics" + case "manga": + return "Manga" + case "audiobooks": + return "Audiobooks" + } + if name == "" { + return name + } + r := []rune(name) + return strings.ToUpper(string(r[0])) + string(r[1:]) +} diff --git a/templates/utils_test.go b/templates/utils_test.go index 474b3bbc..0bb2e74f 100644 --- a/templates/utils_test.go +++ b/templates/utils_test.go @@ -1,6 +1,10 @@ package templates -import "testing" +import ( + "testing" + + "bookhoard/internal/database" +) func TestGroupTunableSettings(t *testing.T) { entries := []SettingEntry{ @@ -54,3 +58,40 @@ func TestThemeCheckClass(t *testing.T) { t.Errorf("inactive theme should be hidden, got %q", got) } } + +// The library-type dropdown renders from DB rows; ordering and labels are +// presentation policy (OrderedLibraryTypes / LibraryTypeDisplayName). +func TestOrderedLibraryTypes(t *testing.T) { + // The DB query returns ORDER BY name; the dropdown must not reshuffle + // the historical UI order, and unknown (future) types append by name. + types := []database.LibraryTypes{ + {Name: "audiobooks"}, {Name: "comics"}, {Name: "ebooks"}, + {Name: "manga"}, {Name: "videos"}, + } + got := OrderedLibraryTypes(types) + want := []string{"ebooks", "comics", "manga", "audiobooks", "videos"} + if len(got) != len(want) { + t.Fatalf("got %d types, want %d", len(got), len(want)) + } + for i, name := range want { + if got[i].Name != name { + t.Errorf("position %d = %q, want %q", i, got[i].Name, name) + } + } +} + +func TestLibraryTypeDisplayName(t *testing.T) { + cases := map[string]string{ + "ebooks": "Ebooks", + "comics": "Comics", + "manga": "Manga", + "audiobooks": "Audiobooks", + "videos": "Videos", // unknown type: capitalized fallback + "": "", + } + for name, want := range cases { + if got := LibraryTypeDisplayName(name); got != want { + t.Errorf("LibraryTypeDisplayName(%q) = %q, want %q", name, got, want) + } + } +}