From a92bf99aee0e8f4ac8d6084d22d4451cad13264e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 19 Feb 2026 21:22:15 -0500 Subject: [PATCH] feat(dashboard): implement Phase 10.5 Custom Section Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10.5.1: Add /custom-section frontend route - Added route handler in internal/router/frontend.go - Fetches user libraries and renders custom section builder template Phase 10.5.2: Create custom section builder template - Created templates/custom_section.templ with full UI - Includes section details form, filter rules builder, manual book selection - Live preview functionality with preview container - Form actions for save/cancel Phase 10.5.3: Create custom-section-builder TypeScript - Created web/src/custom-section-builder.ts with 13+ filter fields - Filter fields: title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators - Procedural/imperative style (no OOP) as per guidelines - Rule builder with AND/OR logic support - Book search and multi-select functionality - Live preview via /api/collections/preview endpoint - Form validation and submission to /api/collections Phase 10.5.4: Build TypeScript modules - Compiled custom-section-builder.ts to web/static/custom-section-builder.js - Verified successful compilation with no errors - All existing TypeScript modules continue to compile Phase 10.5.5: Add Bruno tests for custom section creation - create-custom-section-rules.bru: Test creating section with filter rules - create-custom-section-manual.bru: Test creating section with manual book selection - create-custom-section-missing-fields.bru: Test error handling for missing required fields Phase 10.6: Build Verification - ✅ TypeScript modules compile successfully - ✅ Templates generate successfully - ✅ Go build succeeds with no compilation errors - ✅ All build artifacts verified (dashboard.js, custom-section-builder.js, dashboard_templ.go, custom_section_templ.go) This completes the Custom Section Builder feature, allowing users to create personalized dashboard sections with flexible filter rules or manual book selection. --- .../create-custom-section-manual.bru | 47 ++ .../create-custom-section-missing-fields.bru | 23 + .../create-custom-section-rules.bru | 64 ++ internal/router/frontend.go | 32 + templates/custom_section.templ | 166 +++++ templates/custom_section_templ.go | 84 +++ templates/dashboard_templ.go | 50 +- templates/login_templ.go | 4 +- web/src/custom-section-builder.ts | 569 ++++++++++++++++++ 9 files changed, 1012 insertions(+), 27 deletions(-) create mode 100644 bruno/collections/create-custom-section-manual.bru create mode 100644 bruno/collections/create-custom-section-missing-fields.bru create mode 100644 bruno/collections/create-custom-section-rules.bru create mode 100644 templates/custom_section.templ create mode 100644 templates/custom_section_templ.go create mode 100644 web/src/custom-section-builder.ts diff --git a/bruno/collections/create-custom-section-manual.bru b/bruno/collections/create-custom-section-manual.bru new file mode 100644 index 0000000..59f71d9 --- /dev/null +++ b/bruno/collections/create-custom-section-manual.bru @@ -0,0 +1,47 @@ +meta: + name: Create Custom Section with Manual Books + type: http + seq: 2 +http: + method: POST + url: '{{base_url}}/api/collections' + auth: inherit + body: + type: json + jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"My Favorites\"\ + ,\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\"\ + : true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"{{media_item_id}}\"\ + ],\n \"match_type\": \"all\"\n}" +runtime: + scripts: + - type: tests + code: "test(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n\ + });\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\n\ + expect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains\ + \ collection name\", function() {\n expect(res.body.name).to.eql(\"My Favorites\"\ + );\n });" + +docs: |- + Create a custom collection with manually selected books. + + **Endpoint**: POST /api/collections + **Auth**: Required (Bearer token) + + ## Example Request + + ```json + { + "library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a", + "name": "My Favorites", + "icon": "⭐", + "description": "My favorite books", + "show_on_dashboard": true, + "auto_assign_rules": "", + "manual_book_ids": ["book-id-1", "book-id-2"], + "match_type": "all" + } + ``` + + ## Response + + Returns the created collection with generated ID. diff --git a/bruno/collections/create-custom-section-missing-fields.bru b/bruno/collections/create-custom-section-missing-fields.bru new file mode 100644 index 0000000..37465f8 --- /dev/null +++ b/bruno/collections/create-custom-section-missing-fields.bru @@ -0,0 +1,23 @@ +meta: + name: Create Custom Section Missing Required Fields + type: http + seq: 3 +http: + method: POST + url: '{{base_url}}/api/collections' + auth: inherit + body: + type: json + jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"icon\": \"📚\"\ + \n}" +runtime: + scripts: + - type: tests + code: "test(\"status must be 400 with missing name\", function() {\n expect(res.status).to.eql(400);\n\ + });\n\ntest(\"response contains error message\", function() {\n expect(res.body.error).to.exist;\n\ + \ });" + +docs: |- + Test error handling when creating a collection without required fields. + + **Expected Response**: 400 Bad Request with error message. diff --git a/bruno/collections/create-custom-section-rules.bru b/bruno/collections/create-custom-section-rules.bru new file mode 100644 index 0000000..c970dc6 --- /dev/null +++ b/bruno/collections/create-custom-section-rules.bru @@ -0,0 +1,64 @@ +meta: + name: Create Custom Section with Filter Rules + type: http + seq: 1 +http: + method: POST + url: '{{base_url}}/api/collections' + auth: inherit + body: + type: json + jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"Science Fiction\ + \ Books\",\n \"icon\": \"🚀\",\n \"description\": \"All my sci-fi books\",\n\ + \ \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\ + \"id\\\":\\\"rule1\\\",\\\"field\\\":\\\"genre\\\",\\\"operator\\\":\\\"equals\\\"\ + ,\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\"\ + : [],\n \"match_type\": \"all\"\n}" +runtime: + scripts: + - type: tests + code: "test(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n\ + });\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\n\ + expect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains\ + \ collection name\", function() {\n expect(res.body.name).to.eql(\"Science\ + \ Fiction Books\");\n });\n\ntest(\"response contains is_system false\", function()\ + \ {\n expect(res.body.is_system).to.exist;\n expect(res.body.is_system).to.eql(false);\n\ + \ });" + +docs: |- + Create a custom collection with filter rules. + + **Endpoint**: POST /api/collections + **Auth**: Required (Bearer token) + + ## Request Body + + | Field | Type | Required | Description | + |-------|------|----------|-------------| + | library_id | string | Yes | Library UUID | + | name | string | Yes | Collection name | + | icon | string | No | Emoji icon | + | description | string | No | Collection description | + | show_on_dashboard | boolean | No | Show on dashboard (default: true) | + | auto_assign_rules | string | No | JSON string of filter rules | + | manual_book_ids | array | No | Array of book IDs | + | match_type | string | No | "all" (AND) or "any" (OR) | + + ## Example Request + + ```json + { + "library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a", + "name": "Science Fiction Books", + "icon": "🚀", + "description": "All my sci-fi books", + "show_on_dashboard": true, + "auto_assign_rules": "{\"rules\":[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]}", + "manual_book_ids": [], + "match_type": "all" + } + ``` + + ## Response + + Returns the created collection with generated ID. diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 566811f..e9d47ad 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -190,6 +190,38 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Custom Section Builder page + frontendProtected.GET("/custom-section", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + userUUID, _ := uuid.Parse(user.ID) + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading libraries") + } + + libData := make([]templates.LibraryData, len(libraries)) + for i, lib := range libraries { + libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + libData[i] = templates.LibraryData{ + ID: libUUID.String(), + Name: lib.Name, + Description: getText(lib.Description), + TypeName: lib.TypeName, + } + } + + var buf bytes.Buffer + err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Progress page frontendProtected.GET("/progress", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) diff --git a/templates/custom_section.templ b/templates/custom_section.templ new file mode 100644 index 0000000..1017968 --- /dev/null +++ b/templates/custom_section.templ @@ -0,0 +1,166 @@ +package templates + +templ CustomSectionBuilder(user User, libraries []LibraryData) { + + + + + + Create Custom Section - Bookhoard + + + + + + + + @Header(user, "/custom-section") + +
+

Create Custom Section

+

Build a custom dashboard section by defining filter rules or manually selecting books.

+ +
+ +
+

Section Details

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+
+

Filter Rules

+ +
+ +

+ Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule. +

+ +
+
+ +
+ + +
+
+ + +
+

Manual Book Selection

+

+ Add specific books to this section. Use the search to find and select multiple books. +

+ +
+ +
+ + +
+
+ + + +
+ +
+

No books selected

+
+
+
+ + +
+
+

Live Preview

+ +
+ +
+

+ Add filter rules or select books to see a preview of your custom section. +

+
+
+ + +
+ + +
+
+
+ + +} diff --git a/templates/custom_section_templ.go b/templates/custom_section_templ.go new file mode 100644 index 0000000..32ddacc --- /dev/null +++ b/templates/custom_section_templ.go @@ -0,0 +1,84 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func CustomSectionBuilder(user User, libraries []LibraryData) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Create Custom Section - Bookhoard") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Header(user, "/custom-section").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Create Custom Section

Build a custom dashboard section by defining filter rules or manually selecting books.

Section Details

Filter Rules

Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.

Manual Book Selection

Add specific books to this section. Use the search to find and select multiple books.

No books selected

Live Preview

Add filter rules or select books to see a preview of your custom section.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index 4c13243..a6ea43c 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -55,7 +55,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 36, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 36, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -68,7 +68,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 36, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 36, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -86,7 +86,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 38, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -99,7 +99,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 38, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -165,7 +165,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -178,7 +178,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 82, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -191,7 +191,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 86, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 86, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -204,7 +204,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 88, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 88, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -222,7 +222,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 90, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -245,7 +245,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var12 templ.SafeURL templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 96, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 96, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -263,7 +263,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 111, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -292,7 +292,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 138, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -334,7 +334,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.MediaItemID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 151, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 151, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -347,7 +347,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 154, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -365,7 +365,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 158, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -378,7 +378,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 159, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -396,7 +396,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 165, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -414,7 +414,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 171, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 171, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -432,7 +432,7 @@ func BookCard(item handlers.BookInfo) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 176, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 176, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -484,7 +484,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 204, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 204, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -497,7 +497,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 205, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 205, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -510,7 +510,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 210, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 210, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -523,7 +523,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 212, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 212, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -551,7 +551,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 222, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 222, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -569,7 +569,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 235, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { diff --git a/templates/login_templ.go b/templates/login_templ.go index bbdc088..6ce723b 100644 --- a/templates/login_templ.go +++ b/templates/login_templ.go @@ -29,7 +29,7 @@ func Login(sessionExpired bool) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Login

Login to Bookhoard

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Login

Login to Bookhoard

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -39,7 +39,7 @@ func Login(sessionExpired bool) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Don't have an account? Sign Up

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

Don't have an account? Sign Up

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts new file mode 100644 index 0000000..070632d --- /dev/null +++ b/web/src/custom-section-builder.ts @@ -0,0 +1,569 @@ +import type { BookInfo } from './types/api'; + +interface FilterField { + id: string; + label: string; + operators: Operator[]; + valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect'; + options?: string[]; +} + +interface Operator { + id: string; + label: string; + requiresValue: boolean; +} + +interface FilterRule { + id: string; + field: string; + operator: string; + value: string | string[]; + priority: number; +} + +const FILTER_FIELDS: FilterField[] = [ + { + id: 'title', + label: 'Title', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'starts_with', label: 'Starts With', requiresValue: true }, + { id: 'ends_with', label: 'Ends With', requiresValue: true }, + { id: 'regex', label: 'Matches Regex', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'author', + label: 'Author', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'genre', + label: 'Genre', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + { id: 'not_in', label: 'Not In', requiresValue: true }, + ], + valueType: 'select', + options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'], + }, + { + id: 'series', + label: 'Series', + operators: [ + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'contains', label: 'Contains', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'progress', + label: 'Reading Progress', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'greater_than', label: 'Greater Than', requiresValue: true }, + { id: 'less_than', label: 'Less Than', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'number', + }, + { + id: 'rating', + label: 'Rating', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'greater_than', label: 'Greater Than', requiresValue: true }, + { id: 'less_than', label: 'Less Than', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'number', + }, + { + id: 'date_added', + label: 'Date Added', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'before', label: 'Before', requiresValue: true }, + { id: 'after', label: 'After', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, + ], + valueType: 'date', + }, + { + id: 'last_read', + label: 'Last Read Date', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'before', label: 'Before', requiresValue: true }, + { id: 'after', label: 'After', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'date', + }, + { + id: 'publisher', + label: 'Publisher', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'language', + label: 'Language', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + ], + valueType: 'select', + options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'], + }, + { + id: 'format', + label: 'Format', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + ], + valueType: 'select', + options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'], + }, + { + id: 'tags', + label: 'Tags', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'not_contains', label: 'Does Not Contain', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'narrators', + label: 'Narrators (Audiobooks)', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'text', + }, +]; + +let ruleCounter = 0; +let selectedBooks: Map = new Map(); +let searchTimeout: number | null = null; + +function initCustomSectionBuilder(): void { + const addRuleBtn = document.getElementById('add-rule-btn'); + const previewBtn = document.getElementById('preview-btn'); + const searchBtn = document.getElementById('search-books-btn'); + const bookSearchInput = document.getElementById('book-search'); + const cancelBtn = document.getElementById('cancel-btn'); + const form = document.getElementById('custom-section-form'); + + if (addRuleBtn) { + addRuleBtn.addEventListener('click', addFilterRule); + } + + if (previewBtn) { + previewBtn.addEventListener('click', loadPreview); + } + + if (searchBtn) { + searchBtn.addEventListener('click', searchBooks); + } + + if (bookSearchInput) { + bookSearchInput.addEventListener('input', onBookSearchInput); + bookSearchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchBooks(); + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + window.location.href = '/dashboard'; + }); + } + + if (form) { + form.addEventListener('submit', saveCustomSection); + } +} + +function addFilterRule(): void { + const container = document.getElementById('rules-container'); + if (!container) return; + + ruleCounter++; + const ruleId = `rule-${ruleCounter}`; + + const ruleElement = document.createElement('div'); + ruleElement.className = 'rule-item p-3 rounded border'; + ruleElement.dataset.ruleId = ruleId; + ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; + + ruleElement.innerHTML = ` +
+ + +
+
+ + +
+ `; + + container.appendChild(ruleElement); + + const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; + const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement; + + fieldSelect.addEventListener('change', () => onFieldChange(ruleElement)); + removeBtn.addEventListener('click', () => removeFilterRule(ruleId)); +} + +function onFieldChange(ruleElement: HTMLElement): void { + const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; + const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement; + const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement; + + const fieldId = fieldSelect.value; + const field = FILTER_FIELDS.find(f => f.id === fieldId); + + operatorSelect.innerHTML = field + ? field.operators.map(op => ``).join('') + : ''; + + operatorSelect.disabled = !field; + + if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) { + valueInput.classList.remove('hidden'); + + if (field.valueType === 'select' && field.options) { + valueInput.type = 'select'; + } else if (field.valueType === 'number') { + valueInput.type = 'number'; + valueInput.step = '0.01'; + } else if (field.valueType === 'date') { + valueInput.type = 'date'; + } else { + valueInput.type = 'text'; + } + } else { + valueInput.classList.add('hidden'); + } + + operatorSelect.addEventListener('change', () => { + const selectedOp = field?.operators.find(op => op.id === operatorSelect.value); + if (selectedOp?.requiresValue) { + valueInput.classList.remove('hidden'); + } else { + valueInput.classList.add('hidden'); + } + }); +} + +function removeFilterRule(ruleId: string): void { + const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`); + if (ruleElement) { + ruleElement.remove(); + } +} + +function onBookSearchInput(): void { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + searchTimeout = window.setTimeout(() => { + searchBooks(); + }, 300); +} + +async function searchBooks(): Promise { + const searchInput = document.getElementById('book-search') as HTMLInputElement; + const librarySelect = document.getElementById('section-library') as HTMLSelectElement; + const resultsContainer = document.getElementById('search-results') as HTMLElement; + + const query = searchInput?.value.trim(); + const libraryId = librarySelect?.value; + + if (!query || !libraryId) { + if (resultsContainer) resultsContainer.classList.add('hidden'); + return; + } + + try { + const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to search books'); + } + + const data = await response.json(); + displaySearchResults(data.books || []); + } catch (error) { + console.error('Search books error:', error); + (window as any).showToast?.error('Failed to search books'); + } +} + +function displaySearchResults(books: BookInfo[]): void { + const resultsContainer = document.getElementById('search-results') as HTMLElement; + if (!resultsContainer) return; + + if (books.length === 0) { + resultsContainer.innerHTML = '

No books found

'; + } else { + resultsContainer.innerHTML = books.map(book => ` +
+ ${escapeHtml(book.title)} +
+

${escapeHtml(book.title)}

+

${escapeHtml(book.author)}

+
+ +
+ `).join(''); + } + + resultsContainer.classList.remove('hidden'); +} + +(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void { + if (selectedBooks.has(bookId)) { + (window as any).showToast?.warning('Book already selected'); + return; + } + + selectedBooks.set(bookId, { + media_item_id: bookId, + title: title, + author: author, + cover_image_path: '', + }); + + updateSelectedBooksDisplay(); +}; + +(window as any).removeBookFromSelection = function(bookId: string): void { + selectedBooks.delete(bookId); + updateSelectedBooksDisplay(); +}; + +function updateSelectedBooksDisplay(): void { + const container = document.getElementById('selected-books') as HTMLElement; + if (!container) return; + + if (selectedBooks.size === 0) { + container.innerHTML = '

No books selected

'; + return; + } + + container.innerHTML = Array.from(selectedBooks.values()).map(book => ` +
+ ${escapeHtml(book.title)} + +
+ `).join(''); +} + +async function loadPreview(): Promise { + const previewContainer = document.getElementById('preview-container') as HTMLElement; + const librarySelect = document.getElementById('section-library') as HTMLSelectElement; + const libraryId = librarySelect?.value; + + if (!libraryId) { + (window as any).showToast?.error('Please select a library first'); + return; + } + + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); + + previewContainer.innerHTML = '
'; + + try { + const response = await (window as any).api.post('/collections/preview', { + library_id: libraryId, + rules: rules, + manual_book_ids: manualBookIds, + limit: 20, + }); + + if (response.ok) { + const data = await response.json(); + displayPreview(data.items || []); + } else { + throw new Error('Failed to load preview'); + } + } catch (error) { + console.error('Preview error:', error); + previewContainer.innerHTML = '

Failed to load preview

'; + } +} + +function gatherFilterRules(): FilterRule[] { + const container = document.getElementById('rules-container') as HTMLElement; + if (!container) return []; + + const ruleElements = container.querySelectorAll('.rule-item'); + const rules: FilterRule[] = []; + + ruleElements.forEach((element, index) => { + const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement; + const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement; + const valueInput = element.querySelector('.value-input') as HTMLInputElement; + + if (fieldSelect.value && operatorSelect.value) { + rules.push({ + id: `rule-${index}`, + field: fieldSelect.value, + operator: operatorSelect.value, + value: valueInput.value, + priority: index, + }); + } + }); + + return rules; +} + +function displayPreview(items: BookInfo[]): void { + const previewContainer = document.getElementById('preview-container') as HTMLElement; + if (!previewContainer) return; + + if (items.length === 0) { + previewContainer.innerHTML = '

No items match your criteria

'; + return; + } + + previewContainer.innerHTML = ` +
+ ${items.map(item => ` +
+
+ ${escapeHtml(item.title)} +
+

+ ${escapeHtml(item.title)} +

+ ${item.author ? `

${escapeHtml(item.author)}

` : ''} +
+ `).join('')} +
+

+ ${items.length} item${items.length !== 1 ? 's' : ''} will be shown +

+ `; +} + +async function saveCustomSection(event: Event): Promise { + event.preventDefault(); + + const formData = new FormData(event.target as HTMLFormElement); + const libraryId = formData.get('library_id') as string; + const name = formData.get('name') as string; + const icon = formData.get('icon') as string; + const description = formData.get('description') as string; + const matchType = (document.getElementById('match-type') as HTMLSelectElement).value; + + if (!libraryId || !name) { + (window as any).showToast?.error('Please fill in required fields'); + return; + } + + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); + + if (rules.length === 0 && manualBookIds.length === 0) { + (window as any).showToast?.error('Please add filter rules or select books'); + return; + } + + try { + const response = await (window as any).api.post('/collections', { + library_id: libraryId, + name: name, + icon: icon, + description: description, + show_on_dashboard: true, + auto_assign_rules: JSON.stringify(rules), + manual_book_ids: manualBookIds, + match_type: matchType, + }); + + if (response.ok) { + (window as any).showToast?.success('Custom section created successfully'); + setTimeout(() => { + window.location.href = '/dashboard'; + }, 1000); + } else { + throw new Error('Failed to save custom section'); + } + } catch (error) { + console.error('Save custom section error:', error); + (window as any).showToast?.error('Failed to save custom section'); + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);