feat(dashboard): implement Phase 10.5 Custom Section Builder
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.
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -190,6 +190,38 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
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
|
// Progress page
|
||||||
frontendProtected.GET("/progress", func(c echo.Context) error {
|
frontendProtected.GET("/progress", func(c echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ CustomSectionBuilder(user User, libraries []LibraryData) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create Custom Section - Bookhoard</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/toast.js"></script>
|
||||||
|
<script src="/static/api.js"></script>
|
||||||
|
<script src="/static/custom-section-builder.js"></script>
|
||||||
|
<link href="/static/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="theme-{ user.Theme }">
|
||||||
|
@Header(user, "/custom-section")
|
||||||
|
|
||||||
|
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
|
||||||
|
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
|
||||||
|
|
||||||
|
<form id="custom-section-form" class="space-y-6">
|
||||||
|
<!-- Section Details -->
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
|
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
|
||||||
|
<input type="text" id="section-name" name="name" required
|
||||||
|
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
|
||||||
|
<input type="text" id="section-icon" name="icon" maxlength="4"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
placeholder="📚">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
|
||||||
|
<textarea id="section-description" name="description" rows="2"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
|
||||||
|
<select id="section-library" name="library_id" required
|
||||||
|
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
<option value="">Select a library...</option>
|
||||||
|
for _, lib := range libraries {
|
||||||
|
<option value={ lib.ID }>{ lib.Name }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Rules -->
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
|
||||||
|
<button type="button" id="add-rule-btn"
|
||||||
|
class="px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
style="background-color: var(--accent);">
|
||||||
|
+ Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||||
|
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="rules-container" class="space-y-3">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
|
||||||
|
<select id="match-type" name="match_type"
|
||||||
|
class="px-3 py-1 rounded border"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
<option value="all">ALL rules (AND)</option>
|
||||||
|
<option value="any">ANY rule (OR)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Book Selection -->
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
|
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">
|
||||||
|
Add specific books to this section. Use the search to find and select multiple books.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="book-search" name="book_search"
|
||||||
|
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
placeholder="Search by title or author..."
|
||||||
|
autocomplete="off">
|
||||||
|
<button type="button" id="search-books-btn"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent);">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
|
||||||
|
style="background-color: var(--bg-primary);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
|
||||||
|
<div id="selected-books" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
|
||||||
|
style="border-color: var(--border); background-color: var(--bg-primary);">
|
||||||
|
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Preview -->
|
||||||
|
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
|
||||||
|
<button type="button" id="preview-btn"
|
||||||
|
class="px-4 py-2 rounded-lg font-medium"
|
||||||
|
style="background-color: var(--accent);">
|
||||||
|
Refresh Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-container" class="p-4 rounded-lg"
|
||||||
|
style="background-color: var(--bg-primary); min-height: 200px;">
|
||||||
|
<p class="text-center" style="color: var(--text-secondary);">
|
||||||
|
Add filter rules or select books to see a preview of your custom section.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button type="button" id="cancel-btn"
|
||||||
|
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
|
||||||
|
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="save-section-btn"
|
||||||
|
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
|
||||||
|
style="background-color: var(--accent);">
|
||||||
|
Save Section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Create Custom Section - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/api.js\"></script><script src=\"/static/custom-section-builder.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
|
||||||
|
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, "<main class=\"max-w-4xl mx-auto px-4 py-8\"><h1 class=\"text-3xl font-bold mb-2\" style=\"color: var(--text-primary)\">Create Custom Section</h1><p class=\"mb-6\" style=\"color: var(--text-secondary)\">Build a custom dashboard section by defining filter rules or manually selecting books.</p><form id=\"custom-section-form\" class=\"space-y-6\"><!-- Section Details --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Section Details</h2><div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Name *</label> <input type=\"text\" id=\"section-name\" name=\"name\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></div><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Icon (emoji)</label> <input type=\"text\" id=\"section-icon\" name=\"icon\" maxlength=\"4\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"📚\"></div></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea id=\"section-description\" name=\"description\" rows=\"2\" class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"></textarea></div><div class=\"mt-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Library *</label> <select id=\"section-library\" name=\"library_id\" required class=\"w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Select a library...</option> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, lib := range libraries {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: `custom_section.templ`, Line: 59, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: `custom_section.templ`, Line: 59, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</select></div></div><!-- Filter Rules --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Filter Rules</h2><button type=\"button\" id=\"add-rule-btn\" class=\"px-3 py-1 rounded-lg text-sm font-medium\" style=\"background-color: var(--accent);\">+ Add Rule</button></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.</p><div id=\"rules-container\" class=\"space-y-3\"></div><div class=\"mt-4 flex items-center gap-2\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Match:</label> <select id=\"match-type\" name=\"match_type\" class=\"px-3 py-1 rounded border\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><option value=\"all\">ALL rules (AND)</option> <option value=\"any\">ANY rule (OR)</option></select></div></div><!-- Manual Book Selection --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><h2 class=\"text-xl font-semibold mb-4\" style=\"color: var(--text-primary)\">Manual Book Selection</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Add specific books to this section. Use the search to find and select multiple books.</p><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Search Books</label><div class=\"flex gap-2\"><input type=\"text\" id=\"book-search\" name=\"book_search\" class=\"flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"Search by title or author...\" autocomplete=\"off\"> <button type=\"button\" id=\"search-books-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Search</button></div></div><div id=\"search-results\" class=\"hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto\" style=\"background-color: var(--bg-primary);\"></div><div class=\"mb-4\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Selected Books</label><div id=\"selected-books\" class=\"min-h-[60px] p-3 rounded-lg border-2 border-dashed\" style=\"border-color: var(--border); background-color: var(--bg-primary);\"><p class=\"text-sm text-center\" style=\"color: var(--text-secondary);\">No books selected</p></div></div></div><!-- Live Preview --><div class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-secondary);\"><div class=\"flex items-center justify-between mb-4\"><h2 class=\"text-xl font-semibold\" style=\"color: var(--text-primary)\">Live Preview</h2><button type=\"button\" id=\"preview-btn\" class=\"px-4 py-2 rounded-lg font-medium\" style=\"background-color: var(--accent);\">Refresh Preview</button></div><div id=\"preview-container\" class=\"p-4 rounded-lg\" style=\"background-color: var(--bg-primary); min-height: 200px;\"><p class=\"text-center\" style=\"color: var(--text-secondary);\">Add filter rules or select books to see a preview of your custom section.</p></div></div><!-- Form Actions --><div class=\"flex justify-end gap-3\"><button type=\"button\" id=\"cancel-btn\" class=\"px-6 py-2 rounded-lg font-medium border hover:opacity-80\" style=\"border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);\">Cancel</button> <button type=\"submit\" id=\"save-section-btn\" class=\"px-6 py-2 rounded-lg font-medium text-white hover:opacity-90\" style=\"background-color: var(--accent);\">Save Section</button></div></form></main></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -55,7 +55,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -68,7 +68,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -86,7 +86,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -99,7 +99,7 @@ func Dashboard(user User, sections []handlers.SectionData, libData []LibraryData
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -165,7 +165,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -178,7 +178,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -191,7 +191,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -204,7 +204,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -222,7 +222,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -245,7 +245,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 templ.SafeURL
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -263,7 +263,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -292,7 +292,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -334,7 +334,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.MediaItemID)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -347,7 +347,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -365,7 +365,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -378,7 +378,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -396,7 +396,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -414,7 +414,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -432,7 +432,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -484,7 +484,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -497,7 +497,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem))
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -510,7 +510,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -523,7 +523,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -551,7 +551,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -569,7 +569,7 @@ func DashboardSettingsModal(sections []handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func Login(sessionExpired bool) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\"><div class=\"container mx-auto px-4 py-8 max-w-md\"><div class=\"flex justify-between items-center mb-8\"><h1 class=\"text-3xl font-bold\">Login to Bookhoard</h1><select id=\"theme-select\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\" onchange=\"changeTheme()\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/theme.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-tokyo-night min-h-screen\"><div class=\"container mx-auto px-4 py-8 max-w-md\"><div class=\"flex justify-between items-center mb-8\"><h1 class=\"text-3xl font-bold\">Login to Bookhoard</h1><select id=\"theme-select\" class=\"px-3 py-2 border rounded\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)\" onchange=\"changeTheme()\"><option value=\"tokyo-night\">Tokyo Night</option> <option value=\"dracula\">Dracula</option> <option value=\"nord\">Nord</option> <option value=\"solarized-dark\">Solarized Dark</option> <option value=\"monokai\">Monokai</option> <option value=\"one-dark-pro\">One Dark Pro</option> <option value=\"material-dark\">Material Dark</option> <option value=\"catppuccin-mocha\">Catppuccin Mocha</option> <option value=\"catppuccin-macchiato\">Catppuccin Macchiato</option> <option value=\"catppuccin-frappe\">Catppuccin Frappé</option> <option value=\"catppuccin-latte\">Catppuccin Latte</option></select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func Login(sessionExpired bool) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 rounded-lg shadow-md border\"><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"btn-primary w-full py-2 rounded\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center\"></div><p class=\"text-center mt-4\"><a href=\"/register\" class=\"text-blue-500 hover:underline\">Don't have an account? Sign Up</a></p></div><script>\n function applyTheme(theme) {\n document.body.className = `theme-${theme} min-h-screen`;\n localStorage.setItem('theme', theme);\n }\n function loadTheme() {\n const theme = localStorage.getItem('theme') || 'tokyo-night';\n applyTheme(theme);\n }\n function changeTheme() {\n const theme = document.getElementById('theme-select').value;\n applyTheme(theme);\n const token = localStorage.getItem('token');\n if (token) {\n fetch('/api/auth/theme', {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': 'Bearer ' + token\n },\n body: JSON.stringify({ theme })\n }).catch(err => console.log('Theme save failed', err));\n }\n }\n document.addEventListener('DOMContentLoaded', () => {\n loadTheme();\n document.getElementById('theme-select').value = localStorage.getItem('theme') || 'tokyo-night';\n });\n </script></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<form hx-post=\"/api/auth/login\" hx-target=\"#result\" hx-swap=\"innerHTML\" class=\"card p-6 rounded-lg shadow-md border\"><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Email or Username</label> <input type=\"text\" name=\"login\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><div class=\"mb-4\"><label class=\"block mb-2\" style=\"color: var(--text-secondary)\">Password</label> <input type=\"password\" name=\"password\" class=\"w-full px-3 py-2 border rounded\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)\" required></div><button type=\"submit\" class=\"btn-primary w-full py-2 rounded\">Sign In</button></form><div id=\"result\" class=\"mt-4 text-center\"></div><p class=\"text-center mt-4\"><a href=\"/register\" class=\"text-blue-500 hover:underline\">Don't have an account? Sign Up</a></p></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, BookInfo> = 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 = `
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<select class="field-select flex-1 px-3 py-1 rounded border"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
<option value="">Select field...</option>
|
||||||
|
${FILTER_FIELDS.map(field => `<option value="${field.id}">${field.label}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select class="operator-select flex-1 px-3 py-1 rounded border"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
disabled>
|
||||||
|
<option value="">Select field first...</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
|
||||||
|
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
|
||||||
|
placeholder="Enter value...">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 => `<option value="${op.id}">${op.label}</option>`).join('')
|
||||||
|
: '<option value="">Select field first...</option>';
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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 = '<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
|
||||||
|
} else {
|
||||||
|
resultsContainer.innerHTML = books.map(book => `
|
||||||
|
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
|
||||||
|
data-book-id="${book.media_item_id}"
|
||||||
|
onclick="addBookToSelection('${book.media_item_id}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
|
||||||
|
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
|
||||||
|
alt="${escapeHtml(book.title)}"
|
||||||
|
class="w-10 h-15 object-cover rounded">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</p>
|
||||||
|
<p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
|
||||||
|
</div>
|
||||||
|
`).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 = '<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
|
||||||
|
style="background-color: var(--accent);">
|
||||||
|
<span>${escapeHtml(book.title)}</span>
|
||||||
|
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
|
||||||
|
class="hover:opacity-70">×</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(): Promise<void> {
|
||||||
|
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 = '<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
|
||||||
|
|
||||||
|
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 = '<p class="text-center text-red-500">Failed to load preview</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
${items.map(item => `
|
||||||
|
<div class="flex-shrink-0 w-32">
|
||||||
|
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
|
||||||
|
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
|
||||||
|
alt="${escapeHtml(item.title)}"
|
||||||
|
class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
|
||||||
|
${escapeHtml(item.title)}
|
||||||
|
</h3>
|
||||||
|
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(item.author)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
|
||||||
|
${items.length} item${items.length !== 1 ? 's' : ''} will be shown
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomSection(event: Event): Promise<void> {
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user