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.
+
+
+
+
+
+}
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.
")
+ 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, "LoginLogin to Bookhoard
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
LoginLogin 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.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)}
+
+ ${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);