feat(collections): smart (dynamic) collections — rules evaluated live at render
Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).
- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
survivor dead effort of the rules-builder UI finally has real
endpoints (rules were never editable after creation; nothing ever
re-evaluated them). First rule converts a user collection to smart;
removing the last reverts it to static. UpdateCollectionRulesJSON
edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
visible-library set (family lens enforced at source) + archived IS
NULL AND missing_scan_count = 0 — any enabled rule matches, title
order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
templ whole-attribute interpolation ("{ expr }" inside a quoted attr
value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
rules builder), ⚡ badge + real per-card counts on the grid (smart =
live matches, static = stored-link count), smart detail renders
matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
app's default exclusion, include_auto=true surfaces smart rows with
live counts; TestRules previews against the SAME candidates the live
render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
with NO library_id returned zero books — QueryType != '' caught user
rows ('filter' default) and routed them to dashboard sections that
never contain them; now only real system query types go there
(handlers + SSR). App-visible symptom: /api/collections/:id without
library_id came back books:[] for every static collection.
Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
This commit is contained in:
1 parent
df556d8208
commit
0a7fbd6f48
17 files changed
+1766
-440
No files matched your search
@@ -0,0 +1,353 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"description": "smart collections test",
|
||||||
|
"color": "blue",
|
||||||
|
"icon": "📚",
|
||||||
|
"collection_type": collectionType,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed")
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
return result["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": operator,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
if enabled != nil {
|
||||||
|
payload["enabled"] = *enabled
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
rule := result["rule"].(map[string]interface{})
|
||||||
|
return rule["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano())
|
||||||
|
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: uniqueName,
|
||||||
|
Description: pgtype.Text{String: "smart collections test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "should create test library")
|
||||||
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = setup.DB.DeleteLibrary(ctx, library.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for title, genre := range titlesByGenre {
|
||||||
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Title: title,
|
||||||
|
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
||||||
|
Genre: pgtype.Text{String: genre, Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "should create test media item")
|
||||||
|
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
bookIDs = append(bookIDs, mediaUUID.String())
|
||||||
|
}
|
||||||
|
return libUUID.String(), bookIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
books, _ := coll["books"].([]interface{})
|
||||||
|
set := make(map[string]bool, len(books))
|
||||||
|
for _, b := range books {
|
||||||
|
if m, ok := b.(map[string]interface{}); ok {
|
||||||
|
if title, ok := m["title"].(string); ok {
|
||||||
|
set[title] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSmartCollections(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
adminID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
|
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
||||||
|
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart SF Book One": "Science Fiction",
|
||||||
|
"Smart SF Book Two": "Science Fiction",
|
||||||
|
"Smart FANT Book": "Fantasy",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rule evaluation ignores stored links: a static collection's own
|
||||||
|
// added item must NOT show up once the collection is smart.
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
||||||
|
|
||||||
|
// Add one hand-picked book (a Fantasy one that the smart rule
|
||||||
|
// will not match — so it must vanish from the render).
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
|
||||||
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, false, staticDetail["is_smart"])
|
||||||
|
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
||||||
|
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
||||||
|
|
||||||
|
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
||||||
|
assert.True(t, memberTitles["Smart SF Book One"])
|
||||||
|
assert.True(t, memberTitles["Smart SF Book Two"])
|
||||||
|
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Diss Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
ruleID := smartDecodeRuleID(t, resp)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
||||||
|
|
||||||
|
// Disable: membership drops to zero at once.
|
||||||
|
falseFlag := false
|
||||||
|
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
||||||
|
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
||||||
|
dReq.Header.Set("Content-Type", "application/json")
|
||||||
|
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
dResp, err := http.DefaultClient.Do(dReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
||||||
|
_ = dResp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Guard Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
|
||||||
|
removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil)
|
||||||
|
removeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
removeResp, err := http.DefaultClient.Do(removeReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, removeResp.StatusCode)
|
||||||
|
_ = removeResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Back Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
ruleID := smartDecodeRuleID(t, resp)
|
||||||
|
|
||||||
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, true, smartDetail["is_smart"])
|
||||||
|
|
||||||
|
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
delResp, err := http.DefaultClient.Do(delReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||||
|
_ = delResp.Body.Close()
|
||||||
|
|
||||||
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
||||||
|
|
||||||
|
// Membership mutations work again on the reverted static.
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// System collections may never carry rules.
|
||||||
|
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var systemID string
|
||||||
|
for _, c := range colls {
|
||||||
|
if c.IsSystemCollection.Bool {
|
||||||
|
systemID = uuid.UUID(c.ID.Bytes).String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, systemID, "admin user should have system collections")
|
||||||
|
|
||||||
|
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
||||||
|
_ = sysResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
||||||
|
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart List Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// Default list (the Android app's read): smart collections hidden.
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
defaultResp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body)
|
||||||
|
var defaultData struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData))
|
||||||
|
for _, c := range defaultData.Collections {
|
||||||
|
assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt-in listing includes them with LIVE counts.
|
||||||
|
scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil)
|
||||||
|
scopeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
scopeResp, err := http.DefaultClient.Do(scopeReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body)
|
||||||
|
var scopeData struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BookCount int `json:"book_count"`
|
||||||
|
IsSmart bool `json:"is_smart"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData))
|
||||||
|
found := false
|
||||||
|
for _, c := range scopeData.Collections {
|
||||||
|
if c.ID == collID {
|
||||||
|
found = true
|
||||||
|
assert.True(t, c.IsSmart)
|
||||||
|
assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "include_auto=true must surface smart collections")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ type Querier interface {
|
|||||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||||
CountAdmins(ctx context.Context) (int64, error)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||||
|
// Live item count for a STATIC collection (rule-free cards on the
|
||||||
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
|
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
@@ -358,6 +362,12 @@ type Querier interface {
|
|||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
|
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
|
// membership comes from the candidate book set instead of collection_items.
|
||||||
|
// The set is scoped to the requesting user's visible libraries (family
|
||||||
|
// lens enforced at the source) and live items only — the same filters the
|
||||||
|
// static collection paths apply.
|
||||||
|
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
||||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
@@ -474,6 +484,9 @@ type Querier interface {
|
|||||||
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
|
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||||
|
// of the JSONB without touching metadata stale clients did not send).
|
||||||
|
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||||
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||||
|
|||||||
@@ -293,6 +293,31 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one
|
||||||
|
SELECT COUNT(*)::int FROM collection_items ci
|
||||||
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND NOT COALESCE(ci.excluded, false)
|
||||||
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountCollectionLiveItemsParams struct {
|
||||||
|
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live item count for a STATIC collection (rule-free cards on the
|
||||||
|
// collections page); same live filter as GetCollectionItems, plus the
|
||||||
|
// future-hybrid excluded exclusion and an optional library scope.
|
||||||
|
func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountCollectionLiveItems, arg.CollectionID, arg.LibraryID)
|
||||||
|
var column_1 int32
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM unlinked_books
|
FROM unlinked_books
|
||||||
@@ -8512,6 +8537,110 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many
|
||||||
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
ORDER BY mi.title ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetVisibleLiveMediaForRulesParams struct {
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
|
// membership comes from the candidate book set instead of collection_items.
|
||||||
|
// The set is scoped to the requesting user's visible libraries (family
|
||||||
|
// lens enforced at the source) and live items only — the same filters the
|
||||||
|
// static collection paths apply.
|
||||||
|
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []MediaItems{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i MediaItems
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.Isbn,
|
||||||
|
&i.Description,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.Series,
|
||||||
|
&i.SeriesNumber,
|
||||||
|
&i.Tags,
|
||||||
|
&i.Asin,
|
||||||
|
&i.DatePublished,
|
||||||
|
&i.Publisher,
|
||||||
|
&i.Contributors,
|
||||||
|
&i.Language,
|
||||||
|
&i.Edition,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.Genre,
|
||||||
|
&i.CopyrightYear,
|
||||||
|
&i.GoodreadsID,
|
||||||
|
&i.OpenlibraryID,
|
||||||
|
&i.GoogleBooksID,
|
||||||
|
&i.AddedByAdminID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ImportedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FormatGroup,
|
||||||
|
&i.FormatMimetype,
|
||||||
|
&i.IsReflowable,
|
||||||
|
&i.HasFixedLayout,
|
||||||
|
&i.TotalCharacters,
|
||||||
|
&i.ChapterCount,
|
||||||
|
&i.EntitlementID,
|
||||||
|
&i.RevisionNumber,
|
||||||
|
&i.KoboContentID,
|
||||||
|
&i.KoboMetadata,
|
||||||
|
&i.MangaType,
|
||||||
|
&i.ReadingDirection,
|
||||||
|
&i.SeriesCount,
|
||||||
|
&i.Volume,
|
||||||
|
&i.Imprint,
|
||||||
|
&i.AgeRating,
|
||||||
|
&i.WebUrl,
|
||||||
|
&i.StoryArc,
|
||||||
|
&i.IsBlackAndWhite,
|
||||||
|
&i.MetadataNotes,
|
||||||
|
&i.CommunityRating,
|
||||||
|
&i.AlternateInfo,
|
||||||
|
&i.ScanInformation,
|
||||||
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ChapterMetadata,
|
||||||
|
&i.LibraryTypeName,
|
||||||
|
&i.TagsSearch,
|
||||||
|
&i.ContributorsSearch,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.OpfIdentifier,
|
||||||
|
&i.OpfUuid,
|
||||||
|
&i.HashConfidence,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM sync_conflicts
|
SELECT 1 FROM sync_conflicts
|
||||||
@@ -12383,6 +12512,39 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one
|
||||||
|
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateCollectionRulesJSONParams struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||||
|
// of the JSONB without touching metadata stale clients did not send).
|
||||||
|
func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules)
|
||||||
|
var i Collections
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Color,
|
||||||
|
&i.Icon,
|
||||||
|
&i.AutoAssignRules,
|
||||||
|
&i.ViewSettings,
|
||||||
|
&i.ShowOnDashboard,
|
||||||
|
&i.QueryType,
|
||||||
|
&i.Priority,
|
||||||
|
&i.IsSystemCollection,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one
|
const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one
|
||||||
UPDATE user_dashboard_preferences
|
UPDATE user_dashboard_preferences
|
||||||
SET hidden_collections = $2,
|
SET hidden_collections = $2,
|
||||||
|
|||||||
@@ -2190,6 +2190,11 @@ SET
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||||
|
-- of the JSONB without touching metadata stale clients did not send).
|
||||||
|
-- name: UpdateCollectionRulesJSON :one
|
||||||
|
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
|
||||||
|
|
||||||
-- Delete collection
|
-- Delete collection
|
||||||
-- name: DeleteCollection :exec
|
-- name: DeleteCollection :exec
|
||||||
DELETE FROM collections WHERE id = $1;
|
DELETE FROM collections WHERE id = $1;
|
||||||
@@ -2628,6 +2633,31 @@ WHERE ci.collection_id = sqlc.narg('collection_id')
|
|||||||
ORDER BY ci.added_at DESC
|
ORDER BY ci.added_at DESC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
|
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||||
|
-- membership comes from the candidate book set instead of collection_items.
|
||||||
|
-- The set is scoped to the requesting user's visible libraries (family
|
||||||
|
-- lens enforced at the source) and live items only — the same filters the
|
||||||
|
-- static collection paths apply.
|
||||||
|
-- name: GetVisibleLiveMediaForRules :many
|
||||||
|
SELECT mi.* FROM media_items mi
|
||||||
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
|
ORDER BY mi.title ASC;
|
||||||
|
|
||||||
|
-- Live item count for a STATIC collection (rule-free cards on the
|
||||||
|
-- collections page); same live filter as GetCollectionItems, plus the
|
||||||
|
-- future-hybrid excluded exclusion and an optional library scope.
|
||||||
|
-- name: CountCollectionLiveItems :one
|
||||||
|
SELECT COUNT(*)::int FROM collection_items ci
|
||||||
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
AND NOT COALESCE(ci.excluded, false)
|
||||||
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0;
|
||||||
|
|
||||||
-- name: GetLibraryItems :many
|
-- name: GetLibraryItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type CreateCollectionRequest struct {
|
|||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bookCount := int32(0)
|
bookCount := int32(0)
|
||||||
|
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
@@ -125,6 +127,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
"description": textToString(collection.Description),
|
"description": textToString(collection.Description),
|
||||||
"color": textToString(collection.Color),
|
"color": textToString(collection.Color),
|
||||||
"icon": textToString(collection.Icon),
|
"icon": textToString(collection.Icon),
|
||||||
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
||||||
"auto_assign_rules": collection.AutoAssignRules,
|
"auto_assign_rules": collection.AutoAssignRules,
|
||||||
"view_settings": collection.ViewSettings,
|
"view_settings": collection.ViewSettings,
|
||||||
"book_count": bookCount,
|
"book_count": bookCount,
|
||||||
@@ -133,7 +136,13 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
|
|
||||||
// Add redirect header for HTMX requests
|
// Add redirect header for HTMX requests
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
c.Response().Header().Set("HX-Redirect", "/collections")
|
// A smart collection starts with an empty ruleset; the builder is
|
||||||
|
// where it becomes real. Static collections head back to the grid.
|
||||||
|
if isSmart {
|
||||||
|
c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules")
|
||||||
|
} else {
|
||||||
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, response)
|
return c.JSON(http.StatusCreated, response)
|
||||||
@@ -158,31 +167,46 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
BookCount int `json:"book_count"`
|
BookCount int `json:"book_count"`
|
||||||
|
IsSmart bool `json:"is_smart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]CollectionResponse, 0, len(collections))
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
||||||
|
if !includeAuto && isSmart {
|
||||||
|
// Legacy default for API clients (the Android app pre-dates
|
||||||
|
// smart collections): rule-driven collections stay opt-in so
|
||||||
|
// their membership could never be stale for old readers.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
bookCount := 0
|
bookCount := 0
|
||||||
|
var libUUID pgtype.UUID
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
libUUID, libErr := uuid.Parse(libraryID)
|
if libParsed, libErr := uuid.Parse(libraryID); libErr == nil {
|
||||||
if libErr == nil {
|
libUUID = pgtype.UUID{Bytes: libParsed, Valid: true}
|
||||||
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
|
}
|
||||||
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
}
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
if isSmart {
|
||||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
||||||
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||||
|
if visErr == nil {
|
||||||
|
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||||
|
LibraryID: libUUID,
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
})
|
})
|
||||||
if countErr == nil {
|
if evalErr == nil {
|
||||||
for _, item := range items {
|
bookCount = len(matches)
|
||||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
|
||||||
bookCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||||
|
LibraryID: libUUID,
|
||||||
|
})
|
||||||
|
if countErr == nil {
|
||||||
|
bookCount = int(count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response = append(response, CollectionResponse{
|
response = append(response, CollectionResponse{
|
||||||
@@ -194,6 +218,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
AutoAssignRules: col.AutoAssignRules,
|
AutoAssignRules: col.AutoAssignRules,
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
|
IsSmart: isSmart,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +261,31 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
||||||
|
if isSmart {
|
||||||
|
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
||||||
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||||
|
LibraryID: libUUID,
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
|
})
|
||||||
|
if evalErr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
||||||
|
}
|
||||||
|
bookList = make([]BookInfo, 0, len(matches))
|
||||||
|
for _, item := range matches {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookList = append(bookList, BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
dashboardSvc := services.NewDashboardService(h.db)
|
dashboardSvc := services.NewDashboardService(h.db)
|
||||||
@@ -323,6 +372,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
"view_settings": viewSettings,
|
"view_settings": viewSettings,
|
||||||
"books": bookList,
|
"books": bookList,
|
||||||
"book_count": len(bookList),
|
"book_count": len(bookList),
|
||||||
|
"is_smart": isSmart,
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -400,6 +450,10 @@ func (h *CollectionHandler) AddBooks(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||||
|
return guardErr
|
||||||
|
}
|
||||||
|
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
@@ -447,6 +501,10 @@ func (h *CollectionHandler) RemoveBook(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||||
|
return guardErr
|
||||||
|
}
|
||||||
|
|
||||||
bookID, err := uuid.Parse(c.Param("bookId"))
|
bookID, err := uuid.Parse(c.Param("bookId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||||
@@ -487,6 +545,10 @@ func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
||||||
|
return guardErr
|
||||||
|
}
|
||||||
|
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
@@ -727,6 +789,170 @@ func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database.
|
|||||||
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
||||||
|
// family lens). Smart-collection evaluation funnels every book candidate
|
||||||
|
// through it so hidden libraries contribute nothing.
|
||||||
|
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
||||||
|
user, ok := c.Get("user").(database.Users)
|
||||||
|
if !ok {
|
||||||
|
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||||
|
}
|
||||||
|
rows, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check library access: %w", err)
|
||||||
|
}
|
||||||
|
ids := make([]pgtype.UUID, 0, len(rows))
|
||||||
|
for _, lib := range rows {
|
||||||
|
if lib.ID.Valid {
|
||||||
|
ids = append(ids, lib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserVisibleLibraryIDs is the exported entry point for the router's
|
||||||
|
// frontend page handlers, which resolve the visible-library set before
|
||||||
|
// evaluating smart-collection rule matches.
|
||||||
|
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
||||||
|
return h.userVisibleLibraryIDs(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionService returns the underlying service so the router's
|
||||||
|
// frontend handlers can share the exact same evaluation the API uses.
|
||||||
|
func (h *CollectionHandler) CollectionService() *services.CollectionService {
|
||||||
|
return h.collectionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectSmartMutation returns a 400 for smart collections: membership is
|
||||||
|
// derived live from rules, so stored link mutations have nothing to act
|
||||||
|
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
|
||||||
|
// books is deliberately unsupported (collection_items.excluded awaits it).
|
||||||
|
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
|
||||||
|
collection, err := h.GetCollectionData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||||
|
}
|
||||||
|
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddRuleRequest struct {
|
||||||
|
Field string `json:"field" validate:"required"`
|
||||||
|
Operator string `json:"operator" validate:"required"`
|
||||||
|
Value string `json:"value" validate:"required"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
||||||
|
// the ruleset. The first rule converts a static collection to smart.
|
||||||
|
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := h.GetCollectionData(c, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
|
}
|
||||||
|
if collection.IsSystemCollection.Bool {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AddRuleRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
||||||
|
Field: req.Field,
|
||||||
|
Operator: req.Operator,
|
||||||
|
Value: req.Value,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Priority: req.Priority,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var ruleErr *services.RuleValidationError
|
||||||
|
if errors.As(err, &ruleErr) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||||
|
"rule": rule,
|
||||||
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
||||||
|
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
||||||
|
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
ruleID := c.Param("ruleId")
|
||||||
|
if ruleID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled" validate:"required"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "rule not found") {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||||
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
||||||
|
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
||||||
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||||
|
}
|
||||||
|
ruleID := c.Param("ruleId")
|
||||||
|
if ruleID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "rule not found") {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||||
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type TestRulesRequest struct {
|
type TestRulesRequest struct {
|
||||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -748,16 +974,32 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
// Candidates mirror what smart collections actually evaluate against:
|
||||||
Limit: 1000,
|
// the requester's visible libraries and live items only — the preview
|
||||||
Offset: 0,
|
// must not promise matches the live render will never produce.
|
||||||
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
var visibleArg []pgtype.UUID
|
||||||
|
if len(visibleIDs) > 0 {
|
||||||
|
visibleArg = visibleIDs
|
||||||
|
}
|
||||||
|
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
||||||
|
LibraryID: pgtype.UUID{},
|
||||||
|
VisibleLibraryIds: visibleArg,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listRows := make([]database.ListMediaItemsRow, len(mediaItems))
|
||||||
|
for i, item := range mediaItems {
|
||||||
|
listRows[i] = mediaItemsToListMediaItemsRow(item)
|
||||||
|
}
|
||||||
|
|
||||||
var matches []BookMatch
|
var matches []BookMatch
|
||||||
for _, item := range mediaItems {
|
for _, item := range listRows {
|
||||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||||
if matchReason != "" {
|
if matchReason != "" {
|
||||||
author := ""
|
author := ""
|
||||||
@@ -886,6 +1128,16 @@ func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart collections are rejected up front per operation so the
|
||||||
|
// caller sees a clean, actionable error instead of silent no-ops.
|
||||||
|
for _, op := range req.Operations {
|
||||||
|
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
|
||||||
|
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
|
||||||
|
return guardErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]map[string]interface{}, 0)
|
results := make([]map[string]interface{}, 0)
|
||||||
successCount := 0
|
successCount := 0
|
||||||
failedCount := 0
|
failedCount := 0
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
|
||||||
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
|
||||||
|
|
||||||
|
// Smart collection rule CRUD (the rules builder persists through
|
||||||
|
// these; type follows rules presence — first rule = smart).
|
||||||
|
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
|
||||||
|
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
|
||||||
|
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
|
||||||
|
|
||||||
// Device shelf mapping routes
|
// Device shelf mapping routes
|
||||||
deviceCollections := protected.Group("/devices/:id/collections")
|
deviceCollections := protected.Group("/devices/:id/collections")
|
||||||
deviceCollections.GET("", cfg.CollectionHandler.GetDeviceMappings)
|
deviceCollections.GET("", cfg.CollectionHandler.GetDeviceMappings)
|
||||||
|
|||||||
+125
-1
@@ -19,8 +19,10 @@ import (
|
|||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/internal/version"
|
"bookhoard/internal/version"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
|
|
||||||
@@ -496,14 +498,56 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
errorMsg = "Error loading collections"
|
errorMsg = "Error loading collections"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live per-card counts: smart collections are rule-matched at
|
||||||
|
// render against the requesting user's visible libraries; static
|
||||||
|
// ones count their stored links. Library filter applies when a
|
||||||
|
// specific library is selected.
|
||||||
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
|
var libScope pgtype.UUID
|
||||||
|
if !libRes.IsAll {
|
||||||
|
if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil {
|
||||||
|
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||||
|
if visErr != nil {
|
||||||
|
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||||
|
visibleIDs = nil
|
||||||
|
}
|
||||||
|
|
||||||
colData := make([]templates.CollectionData, len(collections))
|
colData := make([]templates.CollectionData, len(collections))
|
||||||
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
for i, col := range collections {
|
for i, col := range collections {
|
||||||
|
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
||||||
|
bookCount := 0
|
||||||
|
if isSmart {
|
||||||
|
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||||
|
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
||||||
|
LibraryID: libScope,
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
|
})
|
||||||
|
if evalErr == nil {
|
||||||
|
bookCount = len(matches)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||||
|
LibraryID: libScope,
|
||||||
|
})
|
||||||
|
if countErr == nil {
|
||||||
|
bookCount = int(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
colData[i] = templates.CollectionData{
|
colData[i] = templates.CollectionData{
|
||||||
ID: uuid.UUID(col.ID.Bytes).String(),
|
ID: uuid.UUID(col.ID.Bytes).String(),
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: getText(col.Description),
|
Description: getText(col.Description),
|
||||||
Color: getText(col.Color),
|
Color: getText(col.Color),
|
||||||
Icon: getText(col.Icon),
|
Icon: getText(col.Icon),
|
||||||
|
IsSystem: col.IsSystemCollection.Bool,
|
||||||
|
IsSmart: isSmart,
|
||||||
|
BookCount: bookCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +616,47 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Smart collection rules builder page (the smart editor). User
|
||||||
|
// collections only: system sections keep their hardwired queries.
|
||||||
|
frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
collectionID := c.Param("id")
|
||||||
|
collUUID, err := uuid.Parse(collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
|
||||||
|
}
|
||||||
|
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return renderErrorPage(c, "Collection not found", "not_found")
|
||||||
|
}
|
||||||
|
return renderErrorPage(c, "Error loading collection", "collection_load_error")
|
||||||
|
}
|
||||||
|
if collection.IsSystemCollection.Bool {
|
||||||
|
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
colData := templates.CollectionData{
|
||||||
|
ID: collectionID,
|
||||||
|
Name: collection.Name,
|
||||||
|
Description: getText(collection.Description),
|
||||||
|
Color: getText(collection.Color),
|
||||||
|
Icon: getText(collection.Icon),
|
||||||
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
|
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
// Collection detail page (works for both system and user collections)
|
// Collection detail page (works for both system and user collections)
|
||||||
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
@@ -599,7 +684,44 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
libRes := resolveLibrary(c, cfg, user.ID)
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
libraryID := libRes.LibraryID
|
libraryID := libRes.LibraryID
|
||||||
|
|
||||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
var libScope pgtype.UUID
|
||||||
|
if !libRes.IsAll {
|
||||||
|
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
|
||||||
|
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
colService := cfg.CollectionHandler.CollectionService()
|
||||||
|
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
|
||||||
|
|
||||||
|
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
||||||
|
if visErr != nil {
|
||||||
|
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
||||||
|
visibleIDs = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSmart {
|
||||||
|
// Smart (dynamic) collection: membership is evaluated live —
|
||||||
|
// visible libraries, live items only, any enabled rule matches.
|
||||||
|
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||||
|
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||||
|
LibraryID: libScope,
|
||||||
|
VisibleIDs: visibleIDs,
|
||||||
|
})
|
||||||
|
if evalErr != nil {
|
||||||
|
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||||
|
}
|
||||||
|
bookCards := make([]handlers.BookInfo, 0, len(matches))
|
||||||
|
for _, item := range matches {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards = append(bookCards, handlers.BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: getText(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
books = bookCards
|
||||||
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
||||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||||
if secErr != nil {
|
if secErr != nil {
|
||||||
@@ -684,6 +806,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Color: collection.Color.String,
|
Color: collection.Color.String,
|
||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
|
IsSmart: isSmart,
|
||||||
|
BookCount: len(books),
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -24,8 +24,30 @@ type Rule struct {
|
|||||||
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Priority int `json:"priority"` // 1-10, higher values take precedence
|
Priority int `json:"priority"` // 1-10, higher values take precedence
|
||||||
|
// Enabled gates the rule in the LIVE evaluation used by smart
|
||||||
|
// collections; absent (JSON omitempty) means enabled — rules written
|
||||||
|
// before the field existed keep matching.
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known rule fields and operators — the rules builder UI and the
|
||||||
|
// persisted JSONB documents must agree with this vocabulary.
|
||||||
|
var ruleFields = map[string]bool{
|
||||||
|
"genre": true, "series": true, "author": true, "language": true,
|
||||||
|
"publisher": true, "copyright_year": true, "tags": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var ruleOperators = map[string]bool{
|
||||||
|
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
||||||
|
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleValidationError reports a rule payload the evaluator could never
|
||||||
|
// honor (unknown field/operator, empty value).
|
||||||
|
type RuleValidationError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *RuleValidationError) Error() string { return e.Msg }
|
||||||
|
|
||||||
// RuleEvaluation represents the result of evaluating a rule
|
// RuleEvaluation represents the result of evaluating a rule
|
||||||
type RuleEvaluation struct {
|
type RuleEvaluation struct {
|
||||||
RuleID string `json:"rule_id"`
|
RuleID string `json:"rule_id"`
|
||||||
@@ -244,6 +266,248 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
|
|||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
||||||
|
// rules. "null", empty, and malformed docs mean "no rules".
|
||||||
|
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rules []Rule
|
||||||
|
if err := json.Unmarshal(raw, &rules); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSmartCollection derives the collection type: a non-empty ruleset
|
||||||
|
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
||||||
|
// (stored collection_items links).
|
||||||
|
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
||||||
|
return len(s.ParseRules(raw)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnabledRules filters a ruleset down to the rules that participate in
|
||||||
|
// live evaluation (absent enabled = enabled for backward compatibility).
|
||||||
|
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
||||||
|
enabled := make([]Rule, 0, len(rules))
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Enabled == nil || *rule.Enabled {
|
||||||
|
enabled = append(enabled, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRule checks a rule payload the evaluator will have to honor.
|
||||||
|
func (s *CollectionService) validateRule(rule Rule) error {
|
||||||
|
if !ruleFields[rule.Field] {
|
||||||
|
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
||||||
|
}
|
||||||
|
if !ruleOperators[rule.Operator] {
|
||||||
|
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.Value) == "" {
|
||||||
|
return &RuleValidationError{Msg: "rule value is required"}
|
||||||
|
}
|
||||||
|
if rule.Priority < 0 || rule.Priority > 10 {
|
||||||
|
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRule appends a rule to the collection's ruleset, generating an id
|
||||||
|
// when the caller did not supply one. Adding the first rule CONVERTS a
|
||||||
|
// static collection to smart (type is derived from rules presence).
|
||||||
|
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
||||||
|
if err := s.validateRule(rule); err != nil {
|
||||||
|
return database.Collections{}, Rule{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := s.GetCollection(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, Rule{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := s.ParseRules(collection.AutoAssignRules)
|
||||||
|
if rule.ID == "" {
|
||||||
|
rule.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
|
||||||
|
rulesJSON, marshalErr := json.Marshal(rules)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
AutoAssignRules: rulesJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
|
}
|
||||||
|
return updated, rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRuleEnabled flips a single rule's enabled flag in place.
|
||||||
|
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
||||||
|
collection, err := s.GetCollection(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, err
|
||||||
|
}
|
||||||
|
rules := s.ParseRules(collection.AutoAssignRules)
|
||||||
|
found := false
|
||||||
|
for i := range rules {
|
||||||
|
if rules[i].ID == ruleID {
|
||||||
|
rules[i].Enabled = &enabled
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesJSON, marshalErr := json.Marshal(rules)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
AutoAssignRules: rulesJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
||||||
|
// rule converts the collection back to static (type is derived).
|
||||||
|
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
||||||
|
collection, err := s.GetCollection(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, err
|
||||||
|
}
|
||||||
|
rules := s.ParseRules(collection.AutoAssignRules)
|
||||||
|
kept := make([]Rule, 0, len(rules))
|
||||||
|
found := false
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.ID == ruleID {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, rule)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesJSON, marshalErr := json.Marshal(kept)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||||
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
AutoAssignRules: rulesJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmartEvalOptions scopes a smart collection's live evaluation.
|
||||||
|
type SmartEvalOptions struct {
|
||||||
|
// LibraryID, when valid, restricts matching to one library (the
|
||||||
|
// detail page's library switcher).
|
||||||
|
LibraryID pgtype.UUID
|
||||||
|
// VisibleIDs is the requesting user's visible-library set (family
|
||||||
|
// lens). Empty means no visible-library filter.
|
||||||
|
VisibleIDs []pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
||||||
|
// every visible live book matched by at least one enabled rule is in.
|
||||||
|
// No stored links are consulted — "truly dynamic," always current.
|
||||||
|
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||||
|
enabled := s.EnabledRules(rules)
|
||||||
|
if len(enabled) == 0 {
|
||||||
|
return []database.MediaItems{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleArg []pgtype.UUID
|
||||||
|
if len(opts.VisibleIDs) > 0 {
|
||||||
|
visibleArg = opts.VisibleIDs
|
||||||
|
}
|
||||||
|
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
||||||
|
LibraryID: opts.LibraryID,
|
||||||
|
VisibleLibraryIds: visibleArg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := make([]database.MediaItems, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if s.mediaItemMatchesAnyRule(item, enabled) {
|
||||||
|
matches = append(matches, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
||||||
|
for _, rule := range rules {
|
||||||
|
if s.mediaItemMatchesRule(item, rule) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool {
|
||||||
|
if rule.Enabled != nil && !*rule.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldValue string
|
||||||
|
switch rule.Field {
|
||||||
|
case "genre":
|
||||||
|
if item.Genre.Valid {
|
||||||
|
fieldValue = item.Genre.String
|
||||||
|
}
|
||||||
|
case "series":
|
||||||
|
if item.Series.Valid {
|
||||||
|
fieldValue = item.Series.String
|
||||||
|
}
|
||||||
|
case "author":
|
||||||
|
if item.Author.Valid {
|
||||||
|
fieldValue = item.Author.String
|
||||||
|
}
|
||||||
|
case "language":
|
||||||
|
if item.Language.Valid {
|
||||||
|
fieldValue = item.Language.String
|
||||||
|
}
|
||||||
|
case "publisher":
|
||||||
|
if item.Publisher.Valid {
|
||||||
|
fieldValue = item.Publisher.String
|
||||||
|
}
|
||||||
|
case "copyright_year":
|
||||||
|
if item.CopyrightYear.Valid {
|
||||||
|
fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
||||||
|
}
|
||||||
|
case "tags":
|
||||||
|
if len(item.Tags) > 0 {
|
||||||
|
fieldValue = strings.Join(item.Tags, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.evaluateRule(fieldValue, rule.Operator, rule.Value)
|
||||||
|
}
|
||||||
|
|
||||||
// EvaluateRules evaluates auto-assign rules for a media item
|
// EvaluateRules evaluates auto-assign rules for a media item
|
||||||
func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation {
|
func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation {
|
||||||
var evaluations []RuleEvaluation
|
var evaluations []RuleEvaluation
|
||||||
@@ -260,6 +524,11 @@ func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rule := range sortedRules {
|
for _, rule := range sortedRules {
|
||||||
|
// Disabled rules never match (kept for context in the UI only).
|
||||||
|
if rule.Enabled != nil && !*rule.Enabled {
|
||||||
|
evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false})
|
||||||
|
continue
|
||||||
|
}
|
||||||
eval := RuleEvaluation{
|
eval := RuleEvaluation{
|
||||||
RuleID: rule.ID,
|
RuleID: rule.ID,
|
||||||
Matches: false,
|
Matches: false,
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ templ CollectionModal(collection CollectionData) {
|
|||||||
hx-post="/api/collections"
|
hx-post="/api/collections"
|
||||||
hx-redirect="/collections"
|
hx-redirect="/collections"
|
||||||
>
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Type</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label class="relative cursor-pointer">
|
||||||
|
<input type="radio" name="collection_type" value="static" checked class="peer sr-only"/>
|
||||||
|
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
|
<span class="font-semibold text-sm" style="color: var(--text-primary)">Static</span>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Pick books by hand</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="relative cursor-pointer">
|
||||||
|
<input type="radio" name="collection_type" value="smart" class="peer sr-only"/>
|
||||||
|
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400" style="background-color: var(--bg-primary); border-color: var(--border);">
|
||||||
|
<span class="font-semibold text-sm" style="color: var(--text-primary)">⚡ Smart</span>
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Match books by rules — always live</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 116, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 135, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init="initCollectionRules('{ collection.ID }')">
|
<body class="theme-{ user.Theme }" x-data="collectionRules" x-init={ "initCollectionRules('" + collection.ID + "')" }>
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -23,19 +23,23 @@ templ CollectionRules(user User, collection CollectionData) {
|
|||||||
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
|
<span class="grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</h1>
|
||||||
<p style="color: var(--text-secondary)">Auto-Assign Rules</p>
|
<p class="flex items-center gap-2" style="color: var(--text-secondary)">
|
||||||
|
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||||
|
Rules evaluated live — the collection is always current, no refresh needed
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Existing Rules</h2>
|
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Rules</h2>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-secondary)">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p>
|
||||||
<div id="rules-container" class="space-y-4">
|
<div id="rules-container" class="space-y-4">
|
||||||
<div id="no-rules" class="card p-8 text-center">
|
<div id="no-rules" class="card p-8 text-center">
|
||||||
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
|
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
@Icon("filter", "h-6 w-6")
|
@Icon("filter", "h-6 w-6")
|
||||||
</span>
|
</span>
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">No Rules Yet</h3>
|
||||||
<p style="color: var(--text-secondary)">Create auto-assign rules to automatically add books to this collection</p>
|
<p style="color: var(--text-secondary)">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\" x-data=\"collectionRules\" x-init=\"initCollectionRules('{ collection.ID }')\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\" x-data=\"collectionRules\" x-init=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 14, Col: 117}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -50,7 +63,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -58,33 +71,33 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var3 string
|
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
|
|
||||||
}
|
|
||||||
_, 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, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</h1><p style=\"color: var(--text-secondary)\">Auto-Assign Rules</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Existing Rules</h2><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rules</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -92,20 +105,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Create auto-assign rules to automatically add books to this collection</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 45, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 49, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -113,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -121,7 +134,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-21
@@ -1,6 +1,18 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import "bookhoard/internal/handlers"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pluralizeBooks renders "book"/"books" for count displays.
|
||||||
|
func pluralizeBooks(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "book"
|
||||||
|
}
|
||||||
|
return "books"
|
||||||
|
}
|
||||||
|
|
||||||
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
templ Collection(user User, collections []CollectionData, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -75,7 +87,10 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
>
|
>
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<div class="text-3xl">{ col.Icon }</div>
|
<div class="text-3xl">{ col.Icon }</div>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1 items-center">
|
||||||
|
if col.IsSmart {
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
hx-get={ "/collections/" + col.ID + "/edit-modal" }
|
||||||
hx-target="#modal-container"
|
hx-target="#modal-container"
|
||||||
@@ -98,6 +113,12 @@ templ Collection(user User, collections []CollectionData, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">{ col.Name }</h3>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ col.Description }</p>
|
||||||
|
<p class="text-xs mt-3 flex items-center gap-1" style="color: var(--text-secondary);">
|
||||||
|
<span class="font-medium">{ fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)) }</span>
|
||||||
|
if col.IsSmart {
|
||||||
|
<span>· rules-matched live</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -131,8 +152,16 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
<div class="grid place-items-center h-14 w-14 rounded-2xl text-3xl" style="background-color: var(--accent-muted); color: { collection.Color }">{ collection.Icon }</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">{ collection.Name }</h1>
|
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2" style="color: var(--text-primary)">
|
||||||
|
{ collection.Name }
|
||||||
|
if collection.IsSmart {
|
||||||
|
<span class="badge text-xs" style="background-color: var(--accent-muted); color: var(--accent);">⚡ Smart</span>
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
<p class="text-sm" style="color: var(--text-secondary)">{ collection.Description }</p>
|
||||||
|
if collection.IsSmart {
|
||||||
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">Membership is rules-matched live — always current, nothing to refresh.</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,22 +181,31 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem {
|
||||||
<button
|
<a
|
||||||
@click="requestBulkRemove()"
|
href={ "/collections/" + collection.ID + "/rules" }
|
||||||
:disabled="selectedBooks.length === 0"
|
class="btn btn-secondary"
|
||||||
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
|
||||||
class="btn btn-danger"
|
|
||||||
>
|
>
|
||||||
@Icon("trash", "h-4 w-4")
|
@Icon("filter", "h-4 w-4")
|
||||||
<span>Remove Selected</span>
|
<span>Edit Rules</span>
|
||||||
</button>
|
</a>
|
||||||
<button
|
if !collection.IsSmart {
|
||||||
@click="$store.bookPicker.open()"
|
<button
|
||||||
class="btn btn-primary"
|
@click="requestBulkRemove()"
|
||||||
>
|
:disabled="selectedBooks.length === 0"
|
||||||
@Icon("plus", "h-4 w-4")
|
:class="selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''"
|
||||||
<span>Add Books</span>
|
class="btn btn-danger"
|
||||||
</button>
|
>
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
<span>Remove Selected</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="$store.bookPicker.open()"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
@Icon("plus", "h-4 w-4")
|
||||||
|
<span>Add Books</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +216,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
<div class="flex-shrink-0 pt-1">
|
<div class="flex-shrink-0 pt-1">
|
||||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||||
<input
|
<input
|
||||||
@@ -221,7 +259,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||||
<button
|
<button
|
||||||
@click="requestRemoveBook('{ book.MediaItemID }')"
|
@click="requestRemoveBook('{ book.MediaItemID }')"
|
||||||
@@ -236,7 +274,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
<div
|
<div
|
||||||
x-data="bookPicker"
|
x-data="bookPicker"
|
||||||
x-show="$store.bookPicker.isOpen"
|
x-show="$store.bookPicker.isOpen"
|
||||||
@@ -385,6 +423,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
|
|||||||
data-id={ collection.ID }
|
data-id={ collection.ID }
|
||||||
data-library-id={ libraryID }
|
data-library-id={ libraryID }
|
||||||
data-is-system={ collection.IsSystem }
|
data-is-system={ collection.IsSystem }
|
||||||
|
data-is-smart={ collection.IsSmart }
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
></div>
|
></div>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+394
-289
@@ -8,7 +8,19 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
import "bookhoard/internal/handlers"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pluralizeBooks renders "book"/"books" for count displays.
|
||||||
|
func pluralizeBooks(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "book"
|
||||||
|
}
|
||||||
|
return "books"
|
||||||
|
}
|
||||||
|
|
||||||
func Collection(user User, collections []CollectionData, errorMessage string) templ.Component {
|
func Collection(user User, collections []CollectionData, errorMessage string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
@@ -97,7 +109,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 83, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -110,7 +122,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 86, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -123,26 +135,36 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 41}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1\"><button hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"flex gap-1 items-center\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if col.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 80, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 95, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"icon-btn\" aria-label=\"Edit collection\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -150,20 +172,20 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button> <button hx-delete=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 104, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-redirect=\"/collections\" hx-confirm=\"Are you sure you want to delete this collection?\" class=\"icon-btn\" aria-label=\"Delete collection\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -171,38 +193,61 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</button></div></div><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 114, Col: 92}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 115, Col: 81}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p><p class=\"text-xs mt-3 flex items-center gap-1\" style=\"color: var(--text-secondary);\"><span class=\"font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 103}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if col.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span>· rules-matched live</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -210,7 +255,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -234,25 +279,25 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var9 == nil {
|
if templ_7745c5c3_Var10 == nil {
|
||||||
templ_7745c5c3_Var9 = templ.NopComponent
|
templ_7745c5c3_Var10 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 138, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body x-data=\"collections\" x-init=\"initCollectionsPage()\" class=\"theme-{ user.Theme }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -264,7 +309,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn btn-secondary mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><a href=\"/collections\" class=\"btn btn-secondary mb-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -272,227 +317,97 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span>Back to Collections</span></a><div class=\"flex items-center gap-4\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl text-3xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Back to Collections</span></a><div class=\"flex items-center gap-4\"><div class=\"grid place-items-center h-14 w-14 rounded-2xl text-3xl\" style=\"background-color: var(--accent-muted); color: { collection.Color }\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var11 string
|
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 153, Col: 166}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div><div><h1 class=\"text-2xl font-bold tracking-tight flex items-center gap-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 156, Col: 25}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></div></div></div><div class=\"mb-6 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span x-show=\"selectedBooks.length > 0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\"></span></div><div class=\"flex flex-wrap gap-2 items-center\"><div class=\"flex-1 min-w-[200px] max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" @input=\"filterCollectionBooks()\" class=\"input\"></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if collection.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</h1><p class=\"text-sm\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 161, Col: 87}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if collection.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Membership is rules-matched live — always current, nothing to refresh.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div></div></div><div class=\"mb-6 flex flex-wrap justify-between items-center gap-4\"><div class=\"flex items-center gap-3\"><h2 class=\"text-lg font-bold tracking-tight\" style=\"color: var(--text-primary)\">Books in this Collection</h2><span x-show=\"selectedBooks.length > 0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\"></span></div><div class=\"flex flex-wrap gap-2 items-center\"><div class=\"flex-1 min-w-[200px] max-w-md\"><input type=\"text\" id=\"collection-search\" placeholder=\"Search within collection...\" @input=\"filterCollectionBooks()\" class=\"input\"></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button @click=\"requestBulkRemove()\" :disabled=\"selectedBooks.length === 0\" :class=\"selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''\" class=\"btn btn-danger\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("/collections/" + collection.ID + "/rules")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 185, Col: 57}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"btn btn-secondary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Icon("filter", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>Add Books</span></button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span>Edit Rules</span></a> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
if !collection.IsSmart {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<button @click=\"requestBulkRemove()\" :disabled=\"selectedBooks.length === 0\" :class=\"selectedBooks.length === 0 ? 'opacity-50 cursor-not-allowed' : ''\" class=\"btn btn-danger\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if len(books) == 0 {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div id=\"empty-state\" class=\"col-span-full card text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, book := range books {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 string
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 84}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" data-author=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 112}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-media-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 147}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"><div class=\"flex gap-4\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if !collection.IsSystem {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"flex-shrink-0 pt-1\"><label class=\"flex items-center cursor-pointer p-2 -m-2\"><input type=\"checkbox\" class=\"w-5 h-5\" :checked=\"selectedBooks.includes('{ book.MediaItemID }')\" @change=\"toggleSelection('{ book.MediaItemID }')\"></label></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex-1 min-w-0\"><a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 194, Col: 47}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2 hover:underline\" style=\"color: var(--text-primary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</h3></a> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if book.Author != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var19 string
|
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var20 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 206, Col: 47}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if book.CoverImagePath != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<img src=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var21 string
|
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 209, Col: 37}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</a></div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if !collection.IsSystem {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"requestRemoveBook('{ book.MediaItemID }')\" class=\"btn btn-secondary w-full\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -500,22 +415,199 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span>Remove from Collection</span></button></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span>Remove Selected</span></button> <button @click=\"$store.bookPicker.open()\" class=\"btn btn-primary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span>Add Books</span></button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div id=\"books-container\" class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(books) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div id=\"empty-state\" class=\"col-span-full card text-center py-16\" style=\"color: var(--text-secondary)\">No books in this collection yet.</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div></div>")
|
for _, book := range books {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"card p-4 rounded-2xl collection-book-card\" data-title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 217, Col: 84}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" data-author=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 217, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" data-media-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 217, Col: 147}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"><div class=\"flex gap-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"flex-shrink-0 pt-1\"><label class=\"flex items-center cursor-pointer p-2 -m-2\"><input type=\"checkbox\" class=\"w-5 h-5\" :checked=\"selectedBooks.includes('{ book.MediaItemID }')\" @change=\"toggleSelection('{ book.MediaItemID }')\"></label></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"flex-1 min-w-0\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 232, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><h3 class=\"font-semibold text-lg mb-1 line-clamp-2 hover:underline\" style=\"color: var(--text-primary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 234, Col: 23}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</h3></a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if book.Author != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<p class=\"text-sm line-clamp-1\" style=\"color: var(--text-secondary)\">by ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 239, Col: 27}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div><div class=\"flex-shrink-0 w-16 sm:w-20\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 244, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if book.CoverImagePath != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 247, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<img src=\"/static/placeholder-book.svg\" alt=\"Cover\" class=\"w-full aspect-[3/4] object-cover rounded-lg shadow-md\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</a></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"mt-3 pt-3 border-t\" style=\"border-color: var(--border);\"><button @click=\"requestRemoveBook('{ book.MediaItemID }')\" class=\"btn btn-secondary w-full\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("trash", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<span>Remove from Collection</span></button></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !collection.IsSystem {
|
if !collection.IsSystem && !collection.IsSmart {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<div x-data=\"bookPicker\" x-show=\"$store.bookPicker.isOpen\" @keyup.escape.window=\"$store.bookPicker.close()\" @click.self=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-2xl w-full max-w-6xl mx-4 my-8\" style=\"display: none; box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div x-data=\"bookPicker\" x-show=\"$store.bookPicker.isOpen\" @keyup.escape.window=\"$store.bookPicker.close()\" @click.self=\"$store.bookPicker.close()\" class=\"fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop x-show=\"$store.bookPicker.isOpen\" x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" class=\"card rounded-2xl w-full max-w-6xl mx-4 my-8\" style=\"display: none; box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Add Books to Collection</h2><button @click=\"$store.bookPicker.close()\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -523,7 +615,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div id=\"book-picker-filters\" class=\"flex flex-wrap gap-3 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"q\" placeholder=\"Search books...\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</button></div><div class=\"p-4 border-b\" style=\"border-color: var(--border);\"><div id=\"book-picker-filters\" class=\"flex flex-wrap gap-3 items-center\"><div class=\"flex-1 min-w-[200px]\"><input type=\"text\" name=\"q\" placeholder=\"Search books...\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"keyup changed delay:300ms\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"author_filter\" placeholder=\"Author\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><div class=\"flex-1 min-w-[150px]\"><input type=\"text\" name=\"genre_filter\" placeholder=\"Genre\" class=\"input\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-target=\"#book-picker-grid\" hx-trigger=\"change\" hx-include=\"#book-picker-filters\"></div><button @click=\"$store.bookPicker.clearFilters()\" class=\"btn btn-ghost\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -531,12 +623,12 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Clear</span></button> <input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></div></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<span>Clear</span></button> <input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></div></div><div id=\"book-picker-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 p-4 max-h-96 overflow-y-auto\" hx-get=\"/api/media-items/search?show_checkbox=true&collection_id={ collection.ID }\" hx-trigger=\"loadBooks\" hx-include=\"#book-picker-filters\"></div><div class=\"p-4 border-t flex justify-between items-center\" style=\"border-color: var(--border);\"><div class=\"text-sm\" style=\"color: var(--text-secondary);\"><span x-text=\"$store.bookPicker.selectedCount\"></span> books selected</div><div class=\"flex gap-2\"><button @click=\"$store.bookPicker.close()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"$store.bookPicker.submit()\" class=\"btn btn-primary\">Add Selected Books</button></div></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div x-show=\"showConfirm\" @click.self=\"closeConfirm()\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><h3 class=\"text-lg font-bold mb-2\" style=\"color: var(--text-primary)\">Remove from Collection</h3><p x-text=\"confirmMessage\" class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\"></p><div class=\"flex justify-end gap-3\"><button @click=\"closeConfirm()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"executeConfirmed()\" class=\"btn btn-danger\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div x-show=\"showConfirm\" @click.self=\"closeConfirm()\" class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"display: none; background-color: var(--surface-overlay);\"><div @click.stop class=\"card p-6 w-full max-w-md mx-4\" style=\"box-shadow: var(--shadow-pop);\"><h3 class=\"text-lg font-bold mb-2\" style=\"color: var(--text-primary)\">Remove from Collection</h3><p x-text=\"confirmMessage\" class=\"text-sm mb-6\" style=\"color: var(--text-secondary)\"></p><div class=\"flex justify-end gap-3\"><button @click=\"closeConfirm()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"executeConfirmed()\" class=\"btn btn-danger\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -544,46 +636,59 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span x-text=\"confirmLabel\"></span></button></div></div></div></body><div id=\"collection-data\" data-id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var22 string
|
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 385, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" data-library-id=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var23 string
|
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 386, Col: 30}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-is-system=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 387, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 423, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" style=\"display: none;\"></div></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-library-id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 424, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" data-is-system=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 425, Col: 39}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-is-smart=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 426, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" style=\"display: none;\"></div></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -607,119 +712,119 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var25 == nil {
|
if templ_7745c5c3_Var28 == nil {
|
||||||
templ_7745c5c3_Var25 = templ.NopComponent
|
templ_7745c5c3_Var28 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
for _, book := range books {
|
for _, book := range books {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"relative cursor-pointer rounded-lg overflow-hidden\" @click=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<div class=\"relative cursor-pointer rounded-lg overflow-hidden\" @click=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 397, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 436, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"><div class=\"relative\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"><div class=\"relative\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.CoverImagePath != "" {
|
if book.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 440, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 440, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<img src=\"/static/placeholder-book.svg\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 403, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 442, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div x-show=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<div x-show=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 406, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 445, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" class=\"absolute inset-0 border-4 rounded-lg pointer-events-none\" style=\"border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);\"></div><div x-show=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"absolute inset-0 border-4 rounded-lg pointer-events-none\" style=\"border-color: var(--accent); background-color: color-mix(in srgb, var(--accent) 20%, transparent);\"></div><div x-show=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var34 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 411, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 450, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓</div></div><p class=\"text-xs mt-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓</div></div><p class=\"text-xs mt-1 line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 457, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type CollectionData struct {
|
|||||||
Color string
|
Color string
|
||||||
Icon string
|
Icon string
|
||||||
IsSystem bool
|
IsSystem bool
|
||||||
|
// IsSmart marks rule-based ("dynamic") collections: membership is
|
||||||
|
// evaluated live at render, not stored in collection_items.
|
||||||
|
IsSmart bool
|
||||||
|
// BookCount is the LIVE count for the card (rule matches for smart
|
||||||
|
// collections, stored links for static ones).
|
||||||
|
BookCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryData struct {
|
type LibraryData struct {
|
||||||
|
|||||||
+7
-76
@@ -2,7 +2,6 @@ import { Alpine } from "./alpine";
|
|||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { createWebSocket } from "./websocket";
|
import { createWebSocket } from "./websocket";
|
||||||
import {
|
import {
|
||||||
getCurrentLibraryId,
|
|
||||||
initLibrarySwitcher,
|
initLibrarySwitcher,
|
||||||
switchWithTransition,
|
switchWithTransition,
|
||||||
} from "./library-switcher";
|
} from "./library-switcher";
|
||||||
@@ -54,81 +53,15 @@ function initializeCollectionWebSocket(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCollections(): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/collections", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
renderCollectionsGrid(data.collections || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load collections:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCollectionsGrid(collections: CollectionData[]): void {
|
|
||||||
const container = document.getElementById("collections-list");
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (collections.length === 0) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="text-center py-16 col-span-full" style="color: var(--text-secondary)">
|
|
||||||
<div class="text-6xl mb-4">📚</div>
|
|
||||||
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Collections Yet</h3>
|
|
||||||
<p class="mb-4">Create collections to organize your books</p>
|
|
||||||
<button hx-get="/collections/create-modal" hx-target="#modal-container" hx-swap="innerHTML"
|
|
||||||
class="btn-primary px-4 py-2 rounded-lg">Create Your First Collection</button>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryId = getCurrentLibraryId();
|
|
||||||
const libraryParam = libraryId ? `?library_id=${libraryId}` : "";
|
|
||||||
|
|
||||||
container.innerHTML = collections
|
|
||||||
.map(
|
|
||||||
(col) => `
|
|
||||||
<div @click="navigateToCollection($el)" data-href="/collections/${col.id}${libraryParam}" class="block">
|
|
||||||
<div class="card p-6 rounded-lg border-l-4 cursor-pointer hover:shadow-lg transition-shadow"
|
|
||||||
style="background-color: var(--bg-secondary);"
|
|
||||||
data-color="${col.color}">
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
|
||||||
<div class="text-3xl">${col.icon}</div>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button hx-get="/collections/${col.id}/edit-modal" hx-target="#modal-container"
|
|
||||||
hx-swap="innerHTML" class="p-2 hover:opacity-80 rounded"
|
|
||||||
style="color: var(--text-secondary); background-color: var(--bg-primary);">✏️</button>
|
|
||||||
<button hx-delete="/api/collections/${col.id}" hx-redirect="/collections"
|
|
||||||
hx-confirm="Are you sure you want to delete this collection?"
|
|
||||||
class="p-2 hover:opacity-80 rounded"
|
|
||||||
style="color: var(--text-secondary); background-color: var(--bg-primary);">🗑️</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text-primary)">${col.name}</h3>
|
|
||||||
<p class="text-sm mb-4" style="color: var(--text-secondary)">${col.description}</p>
|
|
||||||
${col.book_count > 0 ? `<p class="text-xs" style="color: var(--text-secondary)">${col.book_count} books</p>` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
initColorSelection();
|
|
||||||
Alpine.initTree(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCollectionBooks(books: BookInfo[]): void {
|
function renderCollectionBooks(books: BookInfo[]): void {
|
||||||
const container = document.getElementById("books-container");
|
const container = document.getElementById("books-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const dataEl = document.getElementById("collection-data");
|
const dataEl = document.getElementById("collection-data");
|
||||||
const isSystem = dataEl?.dataset.isSystem === "true";
|
const isSystem = dataEl?.dataset.isSystem === "true";
|
||||||
|
// Smart collections are rule-managed: no hand-pick actions either way.
|
||||||
|
const canEditMembership =
|
||||||
|
!isSystem && dataEl?.dataset.isSmart !== "true";
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
|
container.innerHTML = `<div id="empty-state" class="col-span-full text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div>`;
|
||||||
@@ -140,14 +73,14 @@ function renderCollectionBooks(books: BookInfo[]): void {
|
|||||||
(book) => `
|
(book) => `
|
||||||
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
|
<div class="card p-4 rounded-2xl collection-book-card" data-title="${book.title}" data-author="${book.author || ""}" data-media-id="${book.media_item_id}">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
${isSystem ? "" : `
|
${canEditMembership ? `
|
||||||
<div class="flex-shrink-0 pt-1">
|
<div class="flex-shrink-0 pt-1">
|
||||||
<label class="flex items-center cursor-pointer p-2 -m-2">
|
<label class="flex items-center cursor-pointer p-2 -m-2">
|
||||||
<input type="checkbox" class="w-5 h-5"
|
<input type="checkbox" class="w-5 h-5"
|
||||||
:checked="selectedBooks.includes('${book.media_item_id}')"
|
:checked="selectedBooks.includes('${book.media_item_id}')"
|
||||||
@change="toggleSelection('${book.media_item_id}')"/>
|
@change="toggleSelection('${book.media_item_id}')"/>
|
||||||
</label>
|
</label>
|
||||||
</div>`}
|
</div>` : ""}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<a href="/media/${book.media_item_id}">
|
<a href="/media/${book.media_item_id}">
|
||||||
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
|
<h3 class="font-semibold text-lg mb-1 line-clamp-2 hover:underline" style="color: var(--text-primary)">${book.title}</h3>
|
||||||
@@ -162,12 +95,12 @@ function renderCollectionBooks(books: BookInfo[]): void {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${isSystem ? "" : `
|
${canEditMembership ? `
|
||||||
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
|
||||||
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
|
<button @click="requestRemoveBook('${book.media_item_id}')" class="btn btn-secondary w-full">
|
||||||
🗑️ Remove from Collection
|
🗑️ Remove from Collection
|
||||||
</button>
|
</button>
|
||||||
</div>`}
|
</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
@@ -658,7 +591,6 @@ export {
|
|||||||
initializeCollectionWebSocket,
|
initializeCollectionWebSocket,
|
||||||
initCollectionsPage,
|
initCollectionsPage,
|
||||||
loadCollectionRules,
|
loadCollectionRules,
|
||||||
loadCollections,
|
|
||||||
navigateToCollection,
|
navigateToCollection,
|
||||||
populateIconGrid,
|
populateIconGrid,
|
||||||
selectColor,
|
selectColor,
|
||||||
@@ -680,7 +612,6 @@ Alpine.data("collections", () => ({
|
|||||||
initializeCollectionWebSocket,
|
initializeCollectionWebSocket,
|
||||||
initCollectionsPage,
|
initCollectionsPage,
|
||||||
loadCollectionRules,
|
loadCollectionRules,
|
||||||
loadCollections,
|
|
||||||
navigateToCollection,
|
navigateToCollection,
|
||||||
populateIconGrid,
|
populateIconGrid,
|
||||||
selectColor,
|
selectColor,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user