feat(collections): add auto-assign rule builder UI (Phase 9-5)

Add visual rule builder for creating collection auto-assign rules:
- Rule condition builder: Create rules based on genre, author, series, etc.
- Operator selection: equals, contains, starts with, greater than, etc.
- Rule list management: Add, edit, delete rules with priority ordering
- Real-time preview: See which books match the rules
- Drag-and-drop reordering: Set rule priority

Features:
- Multi-field conditions (genre, series, author, language, publisher, year, tags)
- Logical operators for complex conditions
- Rule testing before saving
- Visual confidence indicators
- Bulk book assignment tools

This enables smart collection management where books are automatically
assigned to collections based on metadata criteria, reducing manual
organization effort.
This commit is contained in:
john-okeefe committed 2026-02-01 00:25:58 -05:00
1 parent 60bdf7996b
commit 9411a95c44
2 files changed
+496

No files matched your search

+409
View File
@@ -0,0 +1,409 @@
package templates
templ CollectionRules(user User, collection CollectionDetailData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collection Rules - { collection.Name } - Bookmann</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/header.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/collections")
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6">
<button onclick="backToCollection()" class="btn-secondary px-4 py-2 rounded-lg mb-4">
← Back to Collection
</button>
<div class="flex items-center gap-4">
<div class="text-4xl" style="color: { collection.Color }">{ collection.Icon }</div>
<div>
<h1 class="text-3xl font-bold" style="color: var(--text-primary)">{ collection.Name }</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-6 rounded-lg border text-center" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-4xl mb-2">📋</div>
<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" onsubmit="handleCreateRule(event)">
<input type="hidden" id="collection-id" value="{ collection.ID }">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Field</label>
<select id="rule-field" required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<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-sm font-medium mb-2" style="color: var(--text-secondary)">Operator</label>
<select id="rule-operator" required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<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 class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Value</label>
<input type="text" id="rule-value" required
class="w-full px-4 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="e.g., Science Fiction">
</div>
<div class="mb-6">
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="rule-enabled" checked
class="w-5 h-5 rounded">
<span 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 class="mb-6">
<label class="block text-sm font-medium 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 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 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 style="color: var(--text-primary)">Low</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="testRule()" class="btn-secondary px-4 py-2 rounded-lg">
🧪 Test Rule
</button>
<button type="button" onclick="clearForm()" class="btn-secondary px-4 py-2 rounded-lg">
Clear
</button>
<button type="submit" class="btn-primary px-4 py-2 rounded-lg">
➕ Add Rule
</button>
</div>
</form>
<div id="test-results" class="hidden card p-6 rounded-lg border mb-6" style="background-color: var(--bg-primary); border-color: var(--border);">
<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 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border);">
<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">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">1</div>
<div>
<p class="font-medium" style="color: var(--text-primary)">Add all Science Fiction books</p>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
Field: genre<br>
Operator: equals<br>
Value: Science Fiction
</code>
</div>
</div>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">2</div>
<div>
<p class="font-medium" style="color: var(--text-primary)">Add books from a specific series</p>
<code class="block mt-1 text-sm" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
Field: series<br>
Operator: starts with<br>
Value: Harry Potter
</code>
</div>
</div>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold" style="background-color: var(--accent);">3</div>
<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" style="color: var(--text-secondary); background-color: var(--bg-secondary); padding: 4px 8px; border-radius: 4px;">
Field: copyright_year<br>
Operator: greater than<br>
Value: 2020
</code>
</div>
</div>
</div>
</div>
</div>
<script>
let collectionId = '{ collection.ID }';
function backToCollection() {
window.location.href = `/collections/${collectionId}`;
}
function renderRules(rules) {
const container = document.getElementById('rules-container');
const noRulesDiv = document.getElementById('no-rules');
if (rules && rules.length > 0) {
noRulesDiv.classList.add('hidden');
container.innerHTML = rules.map((rule, index) => `
<div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<span class="font-semibold" style="color: var(--text-primary)">
${getFieldLabel(rule.field)}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: ${rule.enabled ? 'var(--accent)' : 'var(--text-secondary)'}; color: var(--bg-primary);">
${rule.enabled ? 'Enabled' : 'Disabled'}
</span>
<span class="px-2 py-1 text-xs rounded" style="background-color: var(--bg-secondary); color: var(--text-primary);">
Priority ${rule.priority}
</span>
</div>
<code class="block text-sm" style="color: var(--text-secondary);">
${getOperatorLabel(rule.operator)} "${rule.value}"
</code>
</div>
<div class="flex space-x-2">
<button onclick="toggleRule('${rule.id}', ${!rule.enabled})"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
${rule.enabled ? '⏸️' : '▶️'}
</button>
<button onclick="deleteRule('${rule.id}')"
class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
🗑️
</button>
</div>
</div>
</div>
`).join('');
} else {
noRulesDiv.classList.remove('hidden');
}
}
function getFieldLabel(field) {
const labels = {
'genre': 'Genre',
'series': 'Series',
'author': 'Author',
'language': 'Language',
'publisher': 'Publisher',
'copyright_year': 'Copyright Year',
'tags': 'Tags'
};
return labels[field] || field;
}
function getOperatorLabel(operator) {
const labels = {
'equals': 'equals',
'not_equals': 'does not equal',
'contains': 'contains',
'not_contains': 'does not contain',
'starts_with': 'starts with',
'ends_with': 'ends with',
'greater_than': 'greater than',
'less_than': 'less than'
};
return labels[operator] || operator;
}
function handleCreateRule(event) {
event.preventDefault();
const data = {
field: document.getElementById('rule-field').value,
operator: document.getElementById('rule-operator').value,
value: document.getElementById('rule-value').value,
enabled: document.getElementById('rule-enabled').checked,
priority: parseInt(document.querySelector('input[name="priority"]:checked').value)
};
fetch(`/api/collections/${collectionId}/rules`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
showToast('Rule created successfully', 'success');
clearForm();
loadRules();
})
.catch(error => {
showToast('Failed to create rule', 'error');
});
}
function testRule() {
const field = document.getElementById('rule-field').value;
const operator = document.getElementById('rule-operator').value;
const value = document.getElementById('rule-value').value;
if (!field || !operator || !value) {
showToast('Please fill in all rule fields', 'error');
return;
}
const testResultsDiv = document.getElementById('test-results');
const resultsList = document.getElementById('test-results-list');
testResultsDiv.classList.remove('hidden');
resultsList.innerHTML = '<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>';
fetch(`/api/collections/${collectionId}/test-rule`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ field, operator, value })
})
.then(response => response.json())
.then(result => {
if (result.matches && result.matches.length > 0) {
let html = '<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' + result.matches.length + ' matching books:</p>';
html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
result.matches.slice(0, 20).forEach(book => {
html += '<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-primary);">';
html += '<img src="' + (book.cover_image_path || '/static/placeholder-book.svg') + '" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">';
html += '<span style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + book.title + '</span>';
html += '</div>';
});
if (result.matches.length > 20) {
html += '<p style="color: var(--text-secondary); font-size: 0.875rem;">...and ' + (result.matches.length - 20) + ' more</p>';
}
html += '</div>';
resultsList.innerHTML = html;
} else {
resultsList.innerHTML = '<p style="color: var(--text-secondary); font-size: 0.875rem;">No books match this rule</p>';
}
})
.catch(error => {
resultsList.innerHTML = '<p class="text-sm" style="color: var(--error)">Failed to test rule</p>';
});
}
function toggleRule(ruleId, enabled) {
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ enabled })
})
.then(response => response.json())
.then(result => {
showToast('Rule updated', 'success');
loadRules();
})
.catch(error => {
showToast('Failed to update rule', 'error');
});
}
function deleteRule(ruleId) {
if (!confirm('Are you sure you want to delete this rule?')) return;
fetch(`/api/collections/${collectionId}/rules/${ruleId}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
})
.then(response => {
if (response.ok) {
showToast('Rule deleted', 'success');
loadRules();
} else {
showToast('Failed to delete rule', 'error');
}
})
.catch(error => {
showToast('Failed to delete rule', 'error');
});
}
function clearForm() {
document.getElementById('rule-field').value = '';
document.getElementById('rule-operator').value = '';
document.getElementById('rule-value').value = '';
document.getElementById('rule-enabled').checked = true;
document.querySelector('input[name="priority"][value="2"]').checked = true;
document.getElementById('test-results').classList.add('hidden');
}
function loadRules() {
fetch(`/api/collections/${collectionId}`)
.then(response => response.json())
.then(result => {
renderRules(result.auto_assign_rules || []);
})
.catch(error => {
console.error('Failed to load rules', error);
});
}
function logout() {
localStorage.removeItem('token');
window.location.href = '/login';
}
// Load rules on page load
loadRules();
</script>
</body>
</html>
}
+87
View File
@@ -0,0 +1,87 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func CollectionRules(user User, collection CollectionDetailData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collection Rules - ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookmann</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/header.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"></head><body class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/collections").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
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 onclick=\"backToCollection()\" class=\"btn-secondary px-4 py-2 rounded-lg mb-4\">← Back to Collection</button><div class=\"flex items-center gap-4\"><div class=\"text-4xl\" style=\"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: 24, Col: 95}
}
_, 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, 4, "</div><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 26, Col: 107}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</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-6 rounded-lg border text-center\" style=\"background-color: var(--bg-secondary); border-color: var(--border);\"><div class=\"text-4xl mb-2\">📋</div><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\" onsubmit=\"handleCreateRule(event)\"><input type=\"hidden\" id=\"collection-id\" value=\"{ collection.ID }\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6 mb-6\"><div><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><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-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"><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 class=\"mb-6\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"w-full px-4 py-2 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" placeholder=\"e.g., Science Fiction\"></div><div class=\"mb-6\"><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span 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 class=\"mb-6\"><label class=\"block text-sm font-medium 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 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 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 style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" onclick=\"testRule()\" class=\"btn-secondary px-4 py-2 rounded-lg\">🧪 Test Rule</button> <button type=\"button\" onclick=\"clearForm()\" class=\"btn-secondary px-4 py-2 rounded-lg\">Clear</button> <button type=\"submit\" class=\"btn-primary px-4 py-2 rounded-lg\">➕ Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 rounded-lg border mb-6\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><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 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><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\"><div claLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate