feat(diarization-ui): add multi-select bulk actions to library view

Add checkboxes to each row, select-all/deselect-all controls, and a
bulk action bar for moving or deleting multiple documents at once.
New endpoints: POST /documents/bulk-move and /documents/bulk-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 09:40:27 +01:00
parent 0fab574b68
commit 239875c7f6

94
app.py
View File

@@ -5,7 +5,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import List, Optional
import markdown as md import markdown as md
import requests import requests
@@ -561,6 +561,7 @@ def library(project_id: Optional[str] = None, q_title: str = "", q_content: str
rows = "".join( rows = "".join(
[ [
f"<tr>" f"<tr>"
f"<td class='cb-cell'><input type='checkbox' class='row-cb' value='{d['id']}'></td>"
f"<td>#{d['id']}</td>" f"<td>#{d['id']}</td>"
f"<td>{d['title']}</td>" f"<td>{d['title']}</td>"
f"<td>{d['kind']}</td>" f"<td>{d['kind']}</td>"
@@ -587,6 +588,10 @@ def library(project_id: Optional[str] = None, q_title: str = "", q_content: str
.grid th,.grid td{{padding:10px 12px;border-bottom:1px solid #1f2937;text-align:left}} .grid th,.grid td{{padding:10px 12px;border-bottom:1px solid #1f2937;text-align:left}}
.grid th{{position:sticky;top:0;background:#0b1222;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.04em}} .grid th{{position:sticky;top:0;background:#0b1222;color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:.04em}}
.grid tr:hover{{background:rgba(56,189,248,.06)}} .grid tr:hover{{background:rgba(56,189,248,.06)}}
.grid tr.selected{{background:rgba(56,189,248,.12)}}
.cb-cell{{width:32px;padding:10px 4px 10px 12px}}
.bulk-bar{{display:none;gap:8px;align-items:center;padding:8px 12px;background:rgba(56,189,248,.08);border-radius:6px;margin-bottom:8px}}
.bulk-bar.active{{display:flex}}
</style> </style>
<h2 style='margin-bottom:6px'>Projekte · Dokumente</h2> <h2 style='margin-bottom:6px'>Projekte · Dokumente</h2>
<div class='hint' style='margin-bottom:10px'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div> <div class='hint' style='margin-bottom:10px'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
@@ -599,18 +604,72 @@ def library(project_id: Optional[str] = None, q_title: str = "", q_content: str
<div class='spacer'></div> <div class='spacer'></div>
<small>{len(docs)} Treffer</small> <small>{len(docs)} Treffer</small>
</form> </form>
<div class='bulk-bar' id='bulkBar'>
<strong id='bulkCount'>0 ausgewählt</strong>
<button type='button' onclick='bulkMove()'>📁 Verschieben</button>
<button type='button' onclick='bulkDelete()' style='color:#f87171'>🗑️ Löschen</button>
</div>
<div class='card gridcard'> <div class='card gridcard'>
<table class='grid'> <table class='grid'>
<thead><tr><th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th></tr></thead> <thead><tr>
<tbody>{rows or "<tr><td colspan='6'>Keine Einträge.</td></tr>"}</tbody> <th class='cb-cell'><input type='checkbox' id='cbAll' title='Alle wählen'></th>
<th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th>
</tr></thead>
<tbody>{rows or "<tr><td colspan='7'>Keine Einträge.</td></tr>"}</tbody>
</table> </table>
</div> </div>
<div style='display:flex;gap:8px;margin-top:8px'>
<button type='button' onclick='selectAll()'>Alle wählen</button>
<button type='button' onclick='selectNone()'>Alle abwählen</button>
</div>
<script> <script>
async function libPost(url, data) {{ async function libPost(url, data) {{
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body:new URLSearchParams(data)}}); const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body:new URLSearchParams(data)}});
if(!r.ok) {{ alert('Fehler '+r.status); return; }} if(!r.ok) {{ alert('Fehler '+r.status); return; }}
location.reload(); location.reload();
}} }}
async function libPostMulti(url, params) {{
const body = new URLSearchParams();
for(const [k,v] of Object.entries(params)) {{
if(Array.isArray(v)) v.forEach(x => body.append(k, x));
else body.append(k, v);
}}
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
if(!r.ok) {{ alert('Fehler '+r.status); return; }}
location.reload();
}}
function getSelected() {{
return [...document.querySelectorAll('.row-cb:checked')].map(cb => cb.value);
}}
function updateBulkBar() {{
const ids = getSelected();
const bar = document.getElementById('bulkBar');
document.getElementById('bulkCount').textContent = ids.length + ' ausgewählt';
bar.classList.toggle('active', ids.length > 0);
document.getElementById('cbAll').indeterminate = ids.length > 0 && ids.length < document.querySelectorAll('.row-cb').length;
document.getElementById('cbAll').checked = ids.length > 0 && ids.length === document.querySelectorAll('.row-cb').length;
}}
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.addEventListener('change', function() {{
this.closest('tr').classList.toggle('selected', this.checked);
updateBulkBar();
}});
}});
document.getElementById('cbAll').addEventListener('change', function() {{
document.querySelectorAll('.row-cb').forEach(cb => {{
cb.checked = this.checked;
cb.closest('tr').classList.toggle('selected', this.checked);
}});
updateBulkBar();
}});
function selectAll() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=true; cb.closest('tr').classList.add('selected'); }});
updateBulkBar();
}}
function selectNone() {{
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=false; cb.closest('tr').classList.remove('selected'); }});
updateBulkBar();
}}
window.libRename = async function(id, current) {{ window.libRename = async function(id, current) {{
const v = await window.uiPrompt('Dokument umbenennen', current || ''); const v = await window.uiPrompt('Dokument umbenennen', current || '');
if(v===null) return; if(v===null) return;
@@ -627,6 +686,21 @@ window.libDelete = async function(id) {{
if(!ok) return; if(!ok) return;
await libPost(`/document/${{id}}/delete`, {{}}); await libPost(`/document/${{id}}/delete`, {{}});
}}; }};
window.bulkMove = async function() {{
const ids = getSelected();
if(!ids.length) return;
const options = {project_js};
const v = await window.uiSelect(`${{ids.length}} Dokumente verschieben`, options, 'Projekt wählen');
if(v===null || v==='') return;
await libPostMulti('/documents/bulk-move', {{ids, project_id:v}});
}};
window.bulkDelete = async function() {{
const ids = getSelected();
if(!ids.length) return;
const ok = await window.uiConfirm(`${{ids.length}} Dokumente löschen?`);
if(!ok) return;
await libPostMulti('/documents/bulk-delete', {{ids}});
}};
</script> </script>
""" """
return layout("Library", body) return layout("Library", body)
@@ -730,6 +804,20 @@ def delete_document(doc_id: int):
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>") return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/documents/bulk-move", response_class=HTMLResponse)
def bulk_move_documents(ids: List[int] = Form(...), project_id: int = Form(...)):
with db() as c:
c.executemany("UPDATE documents SET project_id=? WHERE id=?", [(project_id, i) for i in ids])
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/documents/bulk-delete", response_class=HTMLResponse)
def bulk_delete_documents(ids: List[int] = Form(...)):
with db() as c:
c.executemany("DELETE FROM documents WHERE id=?", [(i,) for i in ids])
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.get("/prompts", response_class=HTMLResponse) @app.get("/prompts", response_class=HTMLResponse)
def prompts_page(): def prompts_page():
with db() as c: with db() as c: