- Deleted templates.CollectionDetailData - using templates.CollectionData everywhere - Deleted templates.BookData - using handlers.BookInfo everywhere - Deleted templates.DeviceData - using handlers.DeviceInfo everywhere - Deleted templates.ProgressItemData - using handlers.ProgressWithMedia everywhere - Deleted templates.convertDevices() helper - Use handlers types directly in templates - Enhanced handlers.ProgressWithMedia with device metadata fields - Added handlers.getDeviceIcon() helper - Updated all templates to import handlers package - Cleaned up unused imports This aligns codebase with templ's design philosophy (use Go types directly, no parallel type system)
422 lines
22 KiB
Templ
422 lines
22 KiB
Templ
package templates
|
||
|
||
templ CollectionRules(user User, collection CollectionData) {
|
||
<!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 } - Bookhoard</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>';
|
||
|
||
const rules = [{
|
||
field: field,
|
||
operator: operator,
|
||
value: value
|
||
}];
|
||
|
||
fetch('/api/collections/test-rules', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||
},
|
||
body: JSON.stringify({ rules: rules })
|
||
})
|
||
.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-secondary);">';
|
||
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 += '<div style="flex: 1;">';
|
||
html += '<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + book.title + '</div>';
|
||
if (book.author) {
|
||
html += '<div style="font-size: 0.75rem; color: var(--text-secondary);">' + book.author + '</div>';
|
||
}
|
||
html += '<div style="font-size: 0.75rem; color: var(--accent);">' + book.match_reason + '</div>';
|
||
html += '</div>';
|
||
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>
|
||
}
|