Files
ComicOPDS/app/templates/smartlists.html

288 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ComicOPDS — Smart Lists</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
.group-card { border-left: 4px solid var(--bs-primary); }
.rule-row .form-select, .rule-row .form-control { min-width: 10rem; }
.monosmall { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9rem; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
<div class="container">
<a class="navbar-brand fw-semibold" href="/dashboard"><i class="bi bi-book-half me-2"></i>ComicOPDS</a>
<div class="ms-auto small text-secondary">
<a class="text-decoration-none me-3" href="/opds"><i class="bi bi-rss me-1"></i>OPDS</a>
<a class="text-decoration-none" href="/dashboard"><i class="bi bi-speedometer2 me-1"></i>Dashboard</a>
</div>
</div>
</nav>
<main class="container my-4">
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header fw-semibold">Create / Edit Smart List</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">List name</label>
<input id="listName" class="form-control" placeholder="e.g., Batman 2024"/>
</div>
<div id="groups" class="vstack gap-3"></div>
<button class="btn btn-outline-primary mt-2" id="addGroup"><i class="bi bi-plus-lg me-1"></i>Add group (OR)</button>
<hr class="my-3">
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label">Sort</label>
<select id="sort" class="form-select">
<option value="issued_desc">Issued (newest first)</option>
<option value="series_number">Series + Number</option>
<option value="title">Title</option>
<option value="publisher">Publisher</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label">Limit</label>
<input id="limit" class="form-control" type="number" min="0" value="0" />
<div class="form-text">0 = unlimited</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label">Distinct</label>
<select id="distinctBy" class="form-select">
<option value="">None</option>
<option value="series_volume">Series + Volume</option>
</select>
</div>
<div class="col-12 col-md-3" id="distinctModeWrap" style="display:none;">
<label class="form-label">Pick</label>
<div class="d-flex gap-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="distinctMode" id="dmLatest" value="latest" checked>
<label class="form-check-label" for="dmLatest">Latest</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="distinctMode" id="dmOldest" value="oldest">
<label class="form-check-label" for="dmOldest">Oldest</label>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary" id="saveList"><i class="bi bi-save me-1"></i>Save list</button>
<button class="btn btn-outline-secondary" id="resetForm">Reset</button>
</div>
<div class="small text-secondary mt-3">
Rules within a group are <b>AND</b>d. Groups are <b>OR</b>d.<br>
Fields: <span class="monosmall">series, title, number, volume, publisher, writer, characters, teams, tags, year, month, day, languageiso, comicvineissue, rel, ext, size, mtime</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-header fw-semibold">Your Smart Lists</div>
<div class="card-body">
<div id="lists" class="row g-3"></div>
</div>
</div>
</div>
</div>
</main>
<template id="groupTpl">
<div class="card group-card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>Group (AND)</div>
<button class="btn btn-sm btn-outline-danger remove-group"><i class="bi bi-x-lg"></i></button>
</div>
<div class="card-body vstack gap-2 rules"></div>
<div class="card-footer">
<button class="btn btn-sm btn-outline-primary add-rule"><i class="bi bi-plus-lg me-1"></i>Add rule</button>
</div>
</div>
</template>
<template id="ruleTpl">
<div class="rule-row d-flex flex-wrap align-items-center gap-2">
<input class="form-check-input not-flag" type="checkbox" title="NOT">
<span class="text-secondary small">NOT</span>
<select class="form-select form-select-sm field">
<option>series</option><option>title</option><option>number</option><option>volume</option>
<option>publisher</option><option>writer</option>
<option>characters</option><option>teams</option><option>tags</option>
<option>year</option><option>month</option><option>day</option>
<option>languageiso</option><option>comicvineissue</option>
<option>rel</option><option>ext</option><option>size</option><option>mtime</option>
<option>format</option>
</select>
<select class="form-select form-select-sm op">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="startswith">starts with</option>
<option value="endswith">ends with</option>
<option value="=">= (numeric)</option><option value="!=">!= (numeric)</option>
<option value=">">&gt; (numeric)</option><option value=">=">&gt;= (numeric)</option>
<option value="<">&lt; (numeric)</option><option value="<=">&lt;= (numeric)</option>
</select>
<input class="form-control form-control-sm value" placeholder="value"/>
<button class="btn btn-sm btn-outline-danger remove-rule"><i class="bi bi-x-lg"></i></button>
</div>
</template>
<script>
const listsEl = document.getElementById('lists');
const groupsEl = document.getElementById('groups');
const groupTpl = document.getElementById('groupTpl');
const ruleTpl = document.getElementById('ruleTpl');
const distinctBySel = document.getElementById('distinctBy');
const distinctModeWrap = document.getElementById('distinctModeWrap');
distinctBySel.addEventListener('change', () => {
distinctModeWrap.style.display = (distinctBySel.value === 'series_volume') ? '' : 'none';
});
function addGroup(data) {
const node = groupTpl.content.cloneNode(true);
const card = node.querySelector('.group-card');
const rules = node.querySelector('.rules');
const addBtn = node.querySelector('.add-rule');
const rmGroup = node.querySelector('.remove-group');
addBtn.onclick = () => addRule(rules);
rmGroup.onclick = () => card.remove();
groupsEl.appendChild(node);
(data?.rules?.length ? data.rules : [{}]).forEach(r => addRule(groupsEl.lastElementChild.querySelector('.rules'), r));
}
function addRule(container, data) {
const node = ruleTpl.content.cloneNode(true);
const row = node.querySelector('.rule-row');
if (data) {
row.querySelector('.not-flag').checked = !!data.not;
row.querySelector('.field').value = data.field || 'series';
row.querySelector('.op').value = data.op || 'contains';
row.querySelector('.value').value = (data.value ?? '');
}
row.querySelector('.remove-rule').onclick = () => row.remove();
container.appendChild(node);
}
function readGroups() {
return Array.from(document.querySelectorAll('.group-card')).map(g => {
const rules = Array.from(g.querySelectorAll('.rule-row')).map(r => ({
not: r.querySelector('.not-flag').checked,
field: r.querySelector('.field').value,
op: r.querySelector('.op').value,
value: r.querySelector('.value').value
}));
return { rules: rules.filter(x => (x.value && x.value.trim()) || ["exists","missing"].includes(x.op)) };
}).filter(g => g.rules.length);
}
function resetForm() {
document.getElementById('listName').value = '';
document.getElementById('sort').value = 'issued_desc';
document.getElementById('limit').value = '0';
distinctBySel.value = '';
distinctModeWrap.style.display = 'none';
document.getElementById('dmLatest').checked = true;
groupsEl.innerHTML = '';
addGroup({rules:[{field:'series',op:'contains',value:''}]});
}
async function loadLists() {
const r = await fetch('/smartlists.json', { credentials:'include' });
const data = await r.json();
listsEl.innerHTML = '';
data.forEach(l => {
const col = document.createElement('div');
col.className = 'col-12';
const distinctTxt = l.distinct_by ? `${l.distinct_by} (${l.distinct_mode || 'latest'})` : '—';
col.innerHTML = `
<div class="card h-100">
<div class="card-body d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${l.name}</div>
<div class="small text-secondary">
${(l.groups||[]).map((g,i)=>'Group '+(i+1)+': '+g.rules.map(r => (r.not?'NOT ':'')+r.field+' '+r.op+' "'+String(r.value||'').replace(/"/g,'&quot;')+'"').join(' AND ')).join(' <b>OR</b> ') || '—'}
</div>
<div class="small text-secondary mt-1">Sort: ${l.sort || 'issued_desc'} • Limit: ${l.limit || 0} • Distinct: ${distinctTxt}</div>
<div class="mt-2"><a class="btn btn-sm btn-outline-primary" href="/opds/smart/${l.slug}">Open in OPDS</a></div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary edit">Edit</button>
<button class="btn btn-outline-danger delete">Delete</button>
</div>
</div>
</div>`;
col.querySelector('.edit').onclick = () => {
document.getElementById('listName').value = l.name;
document.getElementById('sort').value = l.sort || 'issued_desc';
document.getElementById('limit').value = l.limit || 0;
distinctBySel.value = l.distinct_by || '';
distinctModeWrap.style.display = (distinctBySel.value === 'series_volume') ? '' : 'none';
(l.distinct_mode === 'oldest' ? document.getElementById('dmOldest') : document.getElementById('dmLatest')).checked = true;
groupsEl.innerHTML = '';
(l.groups || []).forEach(g => addGroup(g));
};
col.querySelector('.delete').onclick = async () => {
if (!confirm('Delete this smart list?')) return;
await saveLists(data.filter(x => x.slug !== l.slug));
await loadLists();
};
listsEl.appendChild(col);
});
return data;
}
async function saveLists(lists) {
const res = await fetch('/smartlists.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(lists)
});
if (!res.ok) {
alert("Failed to save smartlists: " + res.status);
}
}
document.getElementById('addGroup').onclick = () => addGroup();
document.getElementById('resetForm').onclick = resetForm;
document.getElementById('saveList').onclick = async () => {
const name = document.getElementById('listName').value.trim();
const groups = readGroups();
if (!name || groups.length === 0) { alert('Please provide a name and at least one rule.'); return; }
const sort = document.getElementById('sort').value;
const limit = parseInt(document.getElementById('limit').value || '0', 10);
const distinct_by = distinctBySel.value; // '' or 'series_volume'
const distinct_mode = document.querySelector('input[name="distinctMode"]:checked').value; // latest|oldest
const lists = await loadLists();
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'list');
const existing = lists.find(l => l.slug === slug) || lists.find(l => l.name === name);
const record = { name, slug, groups, sort, limit, distinct_by, distinct_mode };
if (existing) Object.assign(existing, record); else lists.push(record);
await saveLists(lists);
resetForm();
await loadLists();
};
// init
resetForm();
loadLists();
</script>
</body>
</html>