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:
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"`))
|
||||||
|
}
|
||||||
@@ -1143,7 +1143,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
|
||||||
libUUID, libUUID, libraryID != "", visibleIDs)
|
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)
|
||||||
|
}
|
||||||
@@ -81,7 +81,8 @@ func registerSetupRoutes(cfg *Config) {
|
|||||||
return c.Redirect(http.StatusFound, "/")
|
return c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
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)
|
log.Printf("Failed to render setup template: %v", err)
|
||||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -88,9 +90,7 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Library Type</label>
|
<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>
|
<select name="type" class="input" required>
|
||||||
<option value="ebooks">Ebooks</option>
|
@LibraryTypeOptions(libraryTypes)
|
||||||
<option value="comics">Comics</option>
|
|
||||||
<option value="manga">Manga</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
@@ -178,6 +178,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
|
|||||||
</html>
|
</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) {
|
templ LibraryList(user User, libraries []LibraryData, users []User) {
|
||||||
if len(libraries) == 0 {
|
if len(libraries) == 0 {
|
||||||
<div class="card text-center py-16">
|
<div class="card text-center py-16">
|
||||||
|
|||||||
+391
-321
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,8 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ Setup() {
|
import "bookhoard/internal/database"
|
||||||
|
|
||||||
|
templ Setup(libraryTypes []database.LibraryTypes) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -211,9 +213,7 @@ templ Setup() {
|
|||||||
x-model="newLibrary.type"
|
x-model="newLibrary.type"
|
||||||
class="input mt-2"
|
class="input mt-2"
|
||||||
>
|
>
|
||||||
<option value="ebooks">Ebooks</option>
|
@LibraryTypeOptions(libraryTypes)
|
||||||
<option value="comics">Comics</option>
|
|
||||||
<option value="manga">Manga</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+24
-14
@@ -8,7 +8,9 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
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) {
|
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
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -85,7 +87,15 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -93,7 +103,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -101,7 +111,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -109,7 +119,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -117,7 +127,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -125,7 +135,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -133,7 +143,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -141,7 +151,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -149,7 +159,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -157,7 +167,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -165,7 +175,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -173,7 +183,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -181,7 +191,7 @@ func Setup() templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,3 +409,61 @@ func badgeLabel(badge string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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
@@ -1,6 +1,10 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGroupTunableSettings(t *testing.T) {
|
func TestGroupTunableSettings(t *testing.T) {
|
||||||
entries := []SettingEntry{
|
entries := []SettingEntry{
|
||||||
@@ -54,3 +58,40 @@ func TestThemeCheckClass(t *testing.T) {
|
|||||||
t.Errorf("inactive theme should be hidden, got %q", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user