fix(ui): derive Library Type dropdowns from library_types — audiobooks was missing

The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.

Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):

- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
  order (ebooks, comics, manga, audiobooks) and appends unknown future
  types by name; LibraryTypeDisplayName renders labels with a
  capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
  by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
  LibraryService.GetLibraryTypes, falling back to the built-in type
  names on DB failure so the forms stay usable (CreateLibrary still
  resolves the name against the DB, so the fallback is never
  authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
  into the two pages

Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
  /admin/library must offer all four library types, audiobooks exactly
  once (options come from the DB loop, not a hardcode)
This commit is contained in:
John O'Keefe committed 2026-10-18 16:38:03 -04:00
1 parent 327fc2c0f9
commit 9e831085ef
10 files changed
+593 -346

No files matched your search

@@ -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"`))
}
+2 -1
View File
@@ -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
}
+21
View File
@@ -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)
}
+2 -1
View File
@@ -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")
}
+13 -4
View File
@@ -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) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="mb-6">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
<select name="type" class="input" required>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div class="flex justify-end space-x-3">
@@ -178,6 +178,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
</html>
}
// LibraryTypeOptions renders the <option> list for a library-type <select>
// from the DB-backed library_types rows, so new types show up without
// template edits.
templ LibraryTypeOptions(types []database.LibraryTypes) {
for _, t := range types {
<option value={ t.Name }>{ LibraryTypeDisplayName(t.Name) }</option>
}
}
templ LibraryList(user User, libraries []LibraryData, users []User) {
if len(libraries) == 0 {
<div class="card text-center py-16">
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,6 +1,8 @@
package templates
templ Setup() {
import "bookhoard/internal/database"
templ Setup(libraryTypes []database.LibraryTypes) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -211,9 +213,7 @@ templ Setup() {
x-model="newLibrary.type"
class="input mt-2"
>
<option value="ebooks">Ebooks</option>
<option value="comics">Comics</option>
<option value="manga">Manga</option>
@LibraryTypeOptions(libraryTypes)
</select>
</div>
<div>
+24 -14
View File
@@ -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, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\"><option value=\"ebooks\">Ebooks</option> <option value=\"comics\">Comics</option> <option value=\"manga\">Manga</option></select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button></div></template></div><form @submit.prevent=\"addLibrary()\" class=\"space-y-4 card p-4\" style=\"background-color: var(--bg-primary);\"><div><label for=\"lib-name\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Name</label> <input type=\"text\" id=\"lib-name\" x-model=\"newLibrary.name\" placeholder=\"My Ebook Library\" class=\"input mt-2\" required></div><div><label for=\"lib-type\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Library Type</label> <select id=\"lib-type\" x-model=\"newLibrary.type\" class=\"input mt-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = LibraryTypeOptions(libraryTypes).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</select></div><div><label for=\"lib-desc\" class=\"block text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Description (optional)</label> <input type=\"text\" id=\"lib-desc\" x-model=\"newLibrary.description\" placeholder=\"Optional\" class=\"input mt-2\"></div><button type=\"submit\" class=\"btn btn-secondary w-full\" :disabled=\"loading || !newLibrary.name.trim()\" x-text=\"loading ? 'Creating…' : '+ Add This Library'\"></button></form><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 1\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -93,7 +103,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Back</button> <button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-primary\"><span x-text=\"libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -101,7 +111,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</button></div></div><!-- ==================== STEP 3: Configure Folders ==================== --><div x-show=\"currentStep === 3\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Configure Library Folders</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Add the filesystem folders where your media files are stored.</p><div x-show=\"libraries.length === 0\" class=\"text-center py-8\" style=\"color: var(--text-secondary)\"><p>No libraries created. You can add folders later from the admin panel.</p></div><div class=\"space-y-6\"><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center gap-2 mb-3\"><span class=\"grid place-items-center h-8 w-8 rounded-lg\" style=\"background-color: var(--accent-muted); color: var(--accent);\"><span class=\"inline-flex\" x-show=\"lib.type === 'ebooks'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -109,7 +119,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'comics'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -117,7 +127,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"inline-flex\" x-show=\"lib.type === 'manga'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -125,7 +135,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></span><h4 class=\"font-semibold\" style=\"color: var(--text-primary)\" x-text=\"lib.name\"></h4></div><div class=\"space-y-1 mb-3\" x-show=\"lib.folders && lib.folders.length > 0\"><template x-for=\"(folder, fIdx) in (lib.folders || [])\" :key=\"folder\"><div class=\"flex items-center justify-between p-2 rounded-lg text-sm\" style=\"background-color: var(--bg-secondary);\"><span class=\"flex items-center gap-2 truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -133,7 +143,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"truncate\" x-text=\"folder\"></span></span> <button type=\"button\" @click=\"removeFolder(lib.id, folder, fIdx)\" class=\"icon-btn shrink-0\" style=\"color: var(--status-danger);\" aria-label=\"Remove folder\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -141,7 +151,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></template></div><div class=\"flex gap-2\"><input type=\"text\" :placeholder=\"'/path/to/' + lib.type\" :id=\"'folderInput-' + lib.id\" class=\"input flex-1 text-sm\"> <button type=\"button\" @click=\"showFolderBrowser('folderInput-' + lib.id)\" class=\"btn btn-secondary px-3 text-xs\">Browse</button> <button type=\"button\" @click=\"addFolderToLibrary(lib)\" class=\"btn btn-primary px-3 text-xs\">Add</button></div></div></template></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 2\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -149,7 +159,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Back</button> <button type=\"button\" @click=\"currentStep = 4\" class=\"btn btn-primary\">Next: Scan")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -157,7 +167,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div></div><!-- ==================== STEP 4: Scan & Finish ==================== --><div x-show=\"currentStep === 4\" class=\"card p-6\"><h2 class=\"text-2xl font-bold mb-2\" style=\"color: var(--text-primary)\">Scan & Finish</h2><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Review your configuration and start the initial scan.</p><div class=\"space-y-3 mb-6\"><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Server URL</p><p style=\"color: var(--text-primary)\" x-text=\"admin.baseUrl\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-1\" style=\"color: var(--text-secondary)\">Administrator</p><p style=\"color: var(--text-primary)\" x-text=\"admin.username + ' (' + admin.email + ')'\"></p></div><div class=\"card p-4\" style=\"background-color: var(--bg-primary);\"><p class=\"text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Libraries (<span x-text=\"libraries.length\"></span>)</p><template x-for=\"lib in libraries\" :key=\"lib.id\"><div class=\"flex items-start justify-between text-sm py-1\"><div class=\"flex items-center gap-2\"><span style=\"color: var(--text-primary)\" x-text=\"lib.name\"></span> <span class=\"chip\" x-text=\"lib.type\"></span></div><div class=\"text-right\" style=\"color: var(--text-secondary)\"><span x-text=\"(lib.folders || []).length + ' folder(s)'\"></span></div></div></template></div></div><div x-show=\"scanStarted\" class=\"mb-6 space-y-3\"><h3 class=\"font-semibold\" style=\"color: var(--text-primary)\">Scan Progress</h3><div id=\"library-progress-list\" class=\"space-y-3\"><template x-for=\"job in scanJobs\" :key=\"job.jobId\"><div class=\"card p-3\" style=\"background-color: var(--bg-primary);\"><div class=\"flex justify-between items-center mb-2\"><span class=\"font-medium\" style=\"color: var(--text-primary)\" x-text=\"job.libraryName\"></span> <span class=\"text-sm\" style=\"color: var(--text-secondary)\" x-text=\"job.statusText\"></span></div><div class=\"w-full rounded-full h-2\" style=\"background-color: var(--bg-secondary)\"><div class=\"h-2 rounded-full transition-all duration-500\" :style=\"'width: ' + job.progress + '%; background-color: var(--accent);'\"></div></div></div></template></div><div x-show=\"scanComplete\" class=\"card p-4 text-sm\" style=\"border-color: var(--status-success); background-color: color-mix(in srgb, var(--status-success) 12%, transparent); color: var(--status-success);\">Initial scan complete! Your libraries are ready to use.</div></div><div class=\"flex justify-between mt-6\"><button type=\"button\" @click=\"currentStep = 3\" class=\"btn btn-secondary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -165,7 +175,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Back</button><div class=\"flex gap-3\"><button x-show=\"!scanStarted\" type=\"button\" @click=\"startScan()\" class=\"btn btn-primary\" :disabled=\"loading\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -173,7 +183,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Start Scan</button> <button x-show=\"scanStarted && !scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-secondary\" :disabled=\"loading\">Skip to Dashboard</button> <button x-show=\"scanComplete\" type=\"button\" @click=\"finishSetup()\" class=\"btn btn-primary\" :disabled=\"loading\" x-text=\"loading ? 'Finishing…' : 'Go to Dashboard'\"></button></div></div></div></div><div id=\"folder-browser-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Browse Folders</h2><button type=\"button\" @click=\"hideFolderBrowser()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -181,7 +191,7 @@ func Setup() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button></div><div id=\"folder-browser-content\"></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+58
View File
@@ -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:])
}
+42 -1
View File
@@ -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)
}
}
}