feat(gui): Projektansicht mit Ordnern, Unterreitern und angehefteten Aufnahmen
Neuer Reiter neben "Aufnahmen": Projektordner bündeln Aufnahmen zweistufig (Ordner + Unterreiter), auf beiden Ebenen anheftbar. Anheften geht aus dem Projekt heraus (Dropdown mit Filter) oder über den 📌-Knopf in der Aufnahmenliste, der Ordner und Ebene abfragt, neue Ordner anlegt und bestehende Anheftungen zum Lösen zeigt. Klick auf eine angeheftete Aufnahme springt in die Aufnahmen-Ansicht. Ein Pin ist eine Referenz, kein Besitz: die Aufnahme bleibt unverändert in der Liste und darf in mehreren Ordnern hängen. project_pins.tab_id ist INT NOT NULL DEFAULT 0 (0 = Ordner-Ebene) statt NULL, weil MariaDB NULL von UNIQUE-Indizes ausschließt — sonst ließe sich dieselbe Aufnahme mehrfach auf dieselbe Ebene anheften. So macht INSERT IGNORE das Anheften idempotent. purgeRecording räumt project_pins mit ab; getProject blendet Aufnahmen im Papierkorb aus, ohne deren Pin zu löschen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
963ce427da
commit
772385713e
+325
-7
@@ -12,7 +12,10 @@ const app = document.querySelector('#app');
|
||||
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '',
|
||||
// search: `q` is what was actually submitted (not what is being typed), so a
|
||||
// tab switch and back restores the result page instead of an empty form.
|
||||
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false };
|
||||
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false,
|
||||
// Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und
|
||||
// welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters).
|
||||
projectId: null, projectTabId: 0 };
|
||||
|
||||
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
|
||||
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
|
||||
@@ -43,8 +46,8 @@ function render() {
|
||||
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
|
||||
</div>
|
||||
<nav>
|
||||
${['recordings', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
${['recordings', 'projects', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
|
||||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', projects: 'Projektansicht', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||||
</nav>
|
||||
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
|
||||
</header>
|
||||
@@ -55,7 +58,7 @@ function render() {
|
||||
app.querySelector('#recbtn').onclick = toggleRecord;
|
||||
bindMarkerInput();
|
||||
if (state.recording) startTimerUI();
|
||||
({ bluetooth: viewBluetooth, recordings: viewRecordings, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
|
||||
({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
|
||||
}
|
||||
|
||||
// Header record button: start = defaults only (default source = CallRecMix,
|
||||
@@ -458,6 +461,311 @@ async function openFromSearch(recId, tMs) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Modal ----------
|
||||
// Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt
|
||||
// als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen;
|
||||
// gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler).
|
||||
function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) {
|
||||
const back = document.createElement('div');
|
||||
back.className = 'modal-back';
|
||||
back.innerHTML = `
|
||||
<div class="modal card">
|
||||
<h3>${title}</h3>
|
||||
<div class="modal-body">${bodyHtml}</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-cancel>Abbrechen</button>
|
||||
${okText ? `<button class="btn primary" data-ok>${okText}</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(back);
|
||||
const root = back.querySelector('.modal');
|
||||
const close = () => { document.removeEventListener('keydown', onKey); back.remove(); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
back.onclick = (e) => { if (e.target === back) close(); };
|
||||
back.querySelector('[data-cancel]').onclick = close;
|
||||
const ok = back.querySelector('[data-ok]');
|
||||
if (ok && onOk) ok.onclick = async () => {
|
||||
ok.disabled = true;
|
||||
try { if (await onOk(root, close) !== false) close(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
finally { ok.disabled = false; }
|
||||
};
|
||||
if (onReady) onReady(root, close);
|
||||
return { root, close };
|
||||
}
|
||||
|
||||
// ---------- Projektansicht ----------
|
||||
// Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante
|
||||
// Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin
|
||||
// ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in
|
||||
// mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht.
|
||||
function viewProjects() {
|
||||
const v = document.querySelector('#view');
|
||||
if (state.projectId == null) return renderProjectList(v);
|
||||
return renderProjectDetail(v);
|
||||
}
|
||||
|
||||
async function renderProjectList(v) {
|
||||
v.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="proj-head">
|
||||
<h3>Projekte</h3>
|
||||
<button id="pnew" class="btn primary">+ Neuer Ordner</button>
|
||||
</div>
|
||||
<div class="hint">Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.</div>
|
||||
<div id="plist" class="proj-grid">Lade…</div>
|
||||
</div>`;
|
||||
v.querySelector('#pnew').onclick = async () => {
|
||||
const name = prompt('Name des Projektordners:', '');
|
||||
if (name === null || !name.trim()) return;
|
||||
try {
|
||||
const p = await api.post('/api/projects', { name: name.trim() });
|
||||
toast(`Ordner „${p.name}" angelegt`);
|
||||
state.projectId = p.id; state.projectTabId = 0;
|
||||
viewProjects();
|
||||
} catch (e) { toast(e.message, true); }
|
||||
};
|
||||
const listEl = v.querySelector('#plist');
|
||||
let ps;
|
||||
try { ps = await api.get('/api/projects'); }
|
||||
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||||
if (!ps.length) { listEl.innerHTML = '<div class="muted">Noch keine Projekte. Oben einen Ordner anlegen.</div>'; return; }
|
||||
listEl.innerHTML = ps.map(p => `
|
||||
<div class="proj-card" data-open="${p.id}">
|
||||
<div class="proj-ico">📁</div>
|
||||
<div class="grow">
|
||||
<b>${escapeHtml(p.name)}</b>
|
||||
<div class="muted small">${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter</div>
|
||||
</div>
|
||||
<button class="btn icon" data-ren="${p.id}" title="Ordner umbenennen">✏️</button>
|
||||
<button class="btn danger icon" data-del="${p.id}" title="Ordner löschen">🗑</button>
|
||||
</div>`).join('');
|
||||
listEl.querySelectorAll('.proj-card').forEach(c => c.onclick = (e) => {
|
||||
if (e.target.closest('[data-ren]') || e.target.closest('[data-del]')) return;
|
||||
state.projectId = +c.dataset.open; state.projectTabId = 0;
|
||||
viewProjects();
|
||||
});
|
||||
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const p = ps.find(x => x.id == b.dataset.ren);
|
||||
const name = prompt('Ordner umbenennen:', p ? p.name : '');
|
||||
if (name === null || !name.trim()) return;
|
||||
await api.patch('/api/projects/' + b.dataset.ren, { name: name.trim() });
|
||||
renderProjectList(v);
|
||||
});
|
||||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const p = ps.find(x => x.id == b.dataset.del);
|
||||
if (!confirm(`Ordner „${p ? p.name : ''}" löschen?\nUnterreiter und Anheftungen verschwinden mit. Die Aufnahmen selbst bleiben erhalten.`)) return;
|
||||
await api.del('/api/projects/' + b.dataset.del);
|
||||
toast('Ordner gelöscht');
|
||||
renderProjectList(v);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderProjectDetail(v) {
|
||||
v.innerHTML = '<div class="card">Lade…</div>';
|
||||
let p;
|
||||
try { p = await api.get('/api/projects/' + state.projectId); }
|
||||
catch (e) { v.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||||
if (!p) { state.projectId = null; return viewProjects(); }
|
||||
// Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die
|
||||
// Ordner-Ebene, statt eine leere Geisterebene anzuzeigen.
|
||||
if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0;
|
||||
const cur = state.projectTabId;
|
||||
const pins = p.pins.filter(x => x.tab_id === cur);
|
||||
const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : p.name;
|
||||
v.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="proj-head">
|
||||
<button id="pback" class="btn small ghost">← Projekte</button>
|
||||
<h3>📁 ${escapeHtml(p.name)}</h3>
|
||||
<button id="prename" class="btn icon" title="Ordner umbenennen">✏️</button>
|
||||
</div>
|
||||
<div class="proj-tabs">
|
||||
<button class="ptab ${cur === 0 ? 'active' : ''}" data-ptab="0">Ordner</button>
|
||||
${p.tabs.map(t => `<button class="ptab ${cur === t.id ? 'active' : ''}" data-ptab="${t.id}">${escapeHtml(t.name)}
|
||||
<span class="ptab-n">${p.pins.filter(x => x.tab_id === t.id).length}</span></button>`).join('')}
|
||||
<button id="ptabnew" class="ptab add" title="Unterreiter anlegen">+ Reiter</button>
|
||||
</div>
|
||||
<div class="proj-level">
|
||||
<div class="proj-level-head">
|
||||
<b>${escapeHtml(levelName)}</b>
|
||||
<span class="muted small">${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}</span>
|
||||
<span class="grow"></span>
|
||||
${cur ? `<button id="ptabren" class="btn small">✏️ Reiter umbenennen</button>
|
||||
<button id="ptabdel" class="btn small danger">🗑 Reiter löschen</button>` : ''}
|
||||
<button id="ppin" class="btn primary small">📌 Aufnahme anheften</button>
|
||||
</div>
|
||||
<div id="ppins" class="list">${pins.length ? pins.map(r => `
|
||||
<div class="item rec" data-open="${r.id}">
|
||||
<div class="grow">
|
||||
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b> ${analysisBadge(r.analysis_status)}
|
||||
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}</div>
|
||||
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn icon" data-unpin="${r.pin_id}" title="Von dieser Ebene lösen">📌✕</button>
|
||||
</div>`).join('')
|
||||
: '<div class="muted">Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.</div>'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
v.querySelector('#pback').onclick = () => { state.projectId = null; state.projectTabId = 0; viewProjects(); };
|
||||
v.querySelector('#prename').onclick = async () => {
|
||||
const name = prompt('Ordner umbenennen:', p.name);
|
||||
if (name === null || !name.trim()) return;
|
||||
await api.patch('/api/projects/' + p.id, { name: name.trim() });
|
||||
renderProjectDetail(v);
|
||||
};
|
||||
v.querySelectorAll('[data-ptab]').forEach(b => b.onclick = () => {
|
||||
state.projectTabId = +b.dataset.ptab;
|
||||
renderProjectDetail(v);
|
||||
});
|
||||
v.querySelector('#ptabnew').onclick = async () => {
|
||||
const name = prompt('Name des Unterreiters:', '');
|
||||
if (name === null || !name.trim()) return;
|
||||
const t = await api.post(`/api/projects/${p.id}/tabs`, { name: name.trim() });
|
||||
state.projectTabId = t.id;
|
||||
renderProjectDetail(v);
|
||||
};
|
||||
const renBtn = v.querySelector('#ptabren');
|
||||
if (renBtn) renBtn.onclick = async () => {
|
||||
const name = prompt('Unterreiter umbenennen:', levelName);
|
||||
if (name === null || !name.trim()) return;
|
||||
await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() });
|
||||
renderProjectDetail(v);
|
||||
};
|
||||
const delBtn = v.querySelector('#ptabdel');
|
||||
if (delBtn) delBtn.onclick = async () => {
|
||||
if (!confirm(`Unterreiter „${levelName}" löschen?\nDie dort angehefteten Aufnahmen werden nur gelöst, nicht gelöscht.`)) return;
|
||||
await api.del(`/api/projects/${p.id}/tabs/${cur}`);
|
||||
state.projectTabId = 0;
|
||||
renderProjectDetail(v);
|
||||
};
|
||||
v.querySelector('#ppin').onclick = () => pinPickRecordingDialog(p.id, cur, () => renderProjectDetail(v));
|
||||
// Klick auf eine angeheftete Aufnahme → direkt in die Aufnahmen-Ansicht.
|
||||
v.querySelectorAll('#ppins .item.rec').forEach(it => it.onclick = (e) => {
|
||||
if (e.target.closest('[data-unpin]')) return;
|
||||
jumpToRecording(+it.dataset.open);
|
||||
});
|
||||
v.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await api.del(`/api/projects/${p.id}/pins/${b.dataset.unpin}`);
|
||||
toast('Anheftung gelöst');
|
||||
renderProjectDetail(v);
|
||||
});
|
||||
}
|
||||
|
||||
// Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften.
|
||||
// Das Filterfeld schreibt die Optionsliste neu — bei dreistelligen Aufnahme-
|
||||
// zahlen ist Scrollen im Select sonst unbrauchbar.
|
||||
async function pinPickRecordingDialog(projectId, tabId, done) {
|
||||
let recs;
|
||||
try { recs = await api.get('/api/recordings'); }
|
||||
catch (e) { return toast(e.message, true); }
|
||||
if (!recs.length) return toast('Es gibt noch keine Aufnahmen.', true);
|
||||
const opt = r => `<option value="${r.id}">${escapeHtml(r.title) || 'Aufnahme #' + r.id} — ${new Date(r.created_at).toLocaleDateString('de-DE')}</option>`;
|
||||
openModal('Aufnahme anheften', `
|
||||
<label class="lbl">Suche (optional)</label>
|
||||
<input id="pfilter" class="input" placeholder="Titel filtern…"/>
|
||||
<label class="lbl">Aufnahme</label>
|
||||
<select id="prec" class="input" size="10">${recs.map(opt).join('')}</select>`, {
|
||||
okText: '📌 Anheften',
|
||||
onReady: (root) => {
|
||||
const sel = root.querySelector('#prec'), f = root.querySelector('#pfilter');
|
||||
f.oninput = () => {
|
||||
const q = f.value.trim().toLowerCase();
|
||||
const hits = recs.filter(r => !q || (r.title || ('Aufnahme #' + r.id)).toLowerCase().includes(q));
|
||||
sel.innerHTML = hits.map(opt).join('');
|
||||
};
|
||||
f.focus();
|
||||
},
|
||||
onOk: async (root) => {
|
||||
const id = +root.querySelector('#prec').value;
|
||||
if (!id) { toast('Bitte eine Aufnahme wählen', true); return false; }
|
||||
await api.post(`/api/projects/${projectId}/pins`, { recording_id: id, tab_id: tabId });
|
||||
toast('Angeheftet ✓');
|
||||
if (done) done();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Aus der Aufnahmenliste heraus: Ziel (Ordner + Ebene) wählen. Zeigt zugleich
|
||||
// alle bestehenden Anheftungen dieser Aufnahme, damit man sie hier auch wieder
|
||||
// lösen kann.
|
||||
async function pinToProjectDialog(rec, done) {
|
||||
let projects, pins;
|
||||
try { [projects, pins] = await Promise.all([api.get('/api/projects'), api.get(`/api/recordings/${rec.id}/pins`)]); }
|
||||
catch (e) { return toast(e.message, true); }
|
||||
const title = rec.title || 'Aufnahme #' + rec.id;
|
||||
const pinsHtml = () => pins.length ? `
|
||||
<label class="lbl">Bereits angeheftet</label>
|
||||
<div class="list pin-current">${pins.map(p => `
|
||||
<div class="item"><div class="grow">📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}</div>
|
||||
<button class="btn danger icon" data-unpin="${p.pin_id}" data-proj="${p.project_id}" title="Lösen">✕</button></div>`).join('')}</div>` : '';
|
||||
const m = openModal(`„${escapeHtml(title)}" anheften`, `
|
||||
<label class="lbl">Projektordner</label>
|
||||
<select id="pp" class="input">
|
||||
${projects.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('')}
|
||||
<option value="new">+ Neuen Ordner anlegen…</option>
|
||||
</select>
|
||||
<label class="lbl">Ebene</label>
|
||||
<select id="pt" class="input"><option value="0">Ordner-Ebene</option></select>
|
||||
<div id="pcur">${pinsHtml()}</div>`, {
|
||||
okText: '📌 Anheften',
|
||||
onReady: (root) => {
|
||||
const pp = root.querySelector('#pp'), pt = root.querySelector('#pt');
|
||||
// Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen.
|
||||
const loadTabs = async () => {
|
||||
if (pp.value === 'new') { pt.innerHTML = '<option value="0">Ordner-Ebene</option>'; return; }
|
||||
const p = await api.get('/api/projects/' + pp.value);
|
||||
pt.innerHTML = '<option value="0">Ordner-Ebene</option>' +
|
||||
p.tabs.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
|
||||
};
|
||||
pp.onchange = async () => {
|
||||
if (pp.value === 'new') {
|
||||
const name = prompt('Name des neuen Projektordners:', '');
|
||||
if (name && name.trim()) {
|
||||
const np = await api.post('/api/projects', { name: name.trim() });
|
||||
pp.insertAdjacentHTML('afterbegin', `<option value="${np.id}">${escapeHtml(np.name)}</option>`);
|
||||
pp.value = String(np.id);
|
||||
} else { pp.value = projects.length ? String(projects[0].id) : 'new'; }
|
||||
}
|
||||
loadTabs();
|
||||
};
|
||||
bindUnpin(root);
|
||||
if (projects.length) loadTabs();
|
||||
},
|
||||
onOk: async (root) => {
|
||||
const pid = root.querySelector('#pp').value;
|
||||
if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; }
|
||||
await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value });
|
||||
toast('Angeheftet ✓');
|
||||
if (done) done();
|
||||
}
|
||||
});
|
||||
function bindUnpin(root) {
|
||||
root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => {
|
||||
await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`);
|
||||
pins = pins.filter(p => p.pin_id != b.dataset.unpin);
|
||||
root.querySelector('#pcur').innerHTML = pinsHtml();
|
||||
bindUnpin(root);
|
||||
toast('Anheftung gelöst');
|
||||
if (done) done();
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der
|
||||
// Suche, nur ohne Zeitsprung).
|
||||
function jumpToRecording(id) {
|
||||
state.tab = 'recordings';
|
||||
state.selectedId = id;
|
||||
state.trashOpen = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------- Recordings ----------
|
||||
// Analysis-duration counter. Shows the total wall time of a processing run
|
||||
// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
|
||||
@@ -537,9 +845,12 @@ async function refreshRecList() {
|
||||
const listEl = document.querySelector('#reclist');
|
||||
if (!listEl) return; // left the tab
|
||||
if (state.trashOpen) return refreshTrashList(listEl);
|
||||
let recs;
|
||||
let recs, pinCounts = {};
|
||||
try { recs = await api.get('/api/recordings'); }
|
||||
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||||
// Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze
|
||||
// Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert.
|
||||
try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; }
|
||||
// Reschedule the poll ONLY while something is still processing.
|
||||
const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing');
|
||||
const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); };
|
||||
@@ -550,7 +861,7 @@ async function refreshRecList() {
|
||||
// DON'T touch the DOM at all — no flicker, no scroll jump, selection kept.
|
||||
// This is the real fix for "I get pulled away from my recording every 5s":
|
||||
// the 5s poll now no-ops unless a status/title/todo actually changed.
|
||||
const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}`).join('~');
|
||||
const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}|${pinCounts[r.id] || 0}`).join('~');
|
||||
if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; }
|
||||
state.lastListSig = sig;
|
||||
// Preserve the left pane's scroll position across the rebuild.
|
||||
@@ -586,6 +897,8 @@ async function refreshRecList() {
|
||||
<div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div>
|
||||
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn icon pinbtn ${pinCounts[r.id] ? 'pinned' : ''}" data-pin="${r.id}"
|
||||
title="${pinCounts[r.id] ? `In ${pinCounts[r.id]} Projekt(en) angeheftet — verwalten` : 'An ein Projekt anheften'}">📌${pinCounts[r.id] ? `<span class="pin-n">${pinCounts[r.id]}</span>` : ''}</button>
|
||||
<button class="btn icon" data-ren="${r.id}" title="Titel umbenennen">✏️</button>
|
||||
<button class="btn danger icon" data-del="${r.id}" title="In den Papierkorb">🗑</button>
|
||||
</div>`; }).join('');
|
||||
@@ -597,9 +910,14 @@ async function refreshRecList() {
|
||||
}).join('');
|
||||
// Click a row (not one of its buttons) → open in the right pane.
|
||||
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
|
||||
if (e.target.closest('[data-del]') || e.target.closest('[data-ren]')) return;
|
||||
if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return;
|
||||
openRecording(+it.dataset.open);
|
||||
});
|
||||
listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const rec = recs.find(r => r.id == b.dataset.pin);
|
||||
pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); });
|
||||
});
|
||||
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const rec = recs.find(r => r.id == b.dataset.ren);
|
||||
|
||||
Reference in New Issue
Block a user