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:
schakal00783
2026-08-02 23:05:07 +02:00
co-authored by Claude Opus 5
parent 963ce427da
commit 772385713e
7 changed files with 688 additions and 9 deletions
+1
View File
@@ -22,6 +22,7 @@ KI-Zusammenfassung (Gemma-3-4B auf der NPU) — **komplett offline**, keine Clou
| [07 – Betrieb](docs/07-betrieb.md) | Services, Zugänge, Backups, Troubleshooting |
| [08 – Changelog](docs/08-changelog.md) | Chronik aller Entwicklungsschritte |
| [09 – Suche & Verwaltung](docs/09-suche-und-verwaltung.md) | Inhaltssuche mit Zeitstempel-Verweis, Papierkorb, Titel umbenennen |
| [10 – Projektansicht](docs/10-projektansicht.md) | Projektordner, Unterreiter, angeheftete Aufnahmen |
## System-Überblick
+114 -1
View File
@@ -81,6 +81,119 @@ export async function ensureSchema() {
vec MEDIUMTEXT NULL,
INDEX rec_idx (recording_id),
FULLTEXT KEY ft_text (text)) ENGINE=InnoDB`);
// Projektansicht: Ordner (projects) > Unterreiter (project_tabs) > angepinnte
// Aufnahmen (project_pins). Eine Aufnahme kann in beliebig vielen Projekten
// hängen — ein Pin ist eine Referenz, kein Besitz, die Aufnahme selbst bleibt
// in der normalen Liste unverändert.
await pool.query(`CREATE TABLE IF NOT EXISTS projects (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
await pool.query(`CREATE TABLE IF NOT EXISTS project_tabs (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
name VARCHAR(190) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX proj_idx (project_id))`);
// tab_id = 0 heißt „direkt im Ordner“ (Ebene 1). Bewusst 0 statt NULL: in
// MySQL/MariaDB ignoriert ein UNIQUE-Index NULL-Werte, ein und dieselbe
// Aufnahme ließe sich sonst beliebig oft auf die Ordner-Ebene pinnen.
await pool.query(`CREATE TABLE IF NOT EXISTS project_pins (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
tab_id INT NOT NULL DEFAULT 0,
recording_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_pin (project_id, tab_id, recording_id),
INDEX rec_idx (recording_id))`);
}
// --- Projektansicht ----------------------------------------------------------
// Ordnerliste fürs Übersichtsgitter: pro Ordner die Zahl der Unterreiter und
// der (nicht gelöschten) Pins über alle Ebenen.
export async function listProjects() {
const [rows] = await pool.query(`
SELECT p.id, p.name, p.created_at,
(SELECT COUNT(*) FROM project_tabs t WHERE t.project_id = p.id) AS tabs,
(SELECT COUNT(*) FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
WHERE pin.project_id = p.id AND r.deleted_at IS NULL) AS pins
FROM projects p ORDER BY p.name`);
return rows;
}
export async function createProject(name) {
const [res] = await pool.execute('INSERT INTO projects (name) VALUES (?)', [String(name).slice(0, 190)]);
return { id: res.insertId, name };
}
export async function renameProject(id, name) {
await pool.execute('UPDATE projects SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
}
// Der Ordner verschwindet mitsamt Reitern und Pins — die Aufnahmen selbst
// bleiben unberührt (ein Pin ist nur eine Referenz).
export async function deleteProject(id) {
await pool.query('DELETE FROM project_pins WHERE project_id=?', [id]);
await pool.query('DELETE FROM project_tabs WHERE project_id=?', [id]);
await pool.query('DELETE FROM projects WHERE id=?', [id]);
}
export async function createProjectTab(projectId, name) {
const [res] = await pool.execute('INSERT INTO project_tabs (project_id, name) VALUES (?,?)',
[projectId, String(name).slice(0, 190)]);
return { id: res.insertId, project_id: projectId, name };
}
export async function renameProjectTab(id, name) {
await pool.execute('UPDATE project_tabs SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
}
export async function deleteProjectTab(id) {
await pool.query('DELETE FROM project_pins WHERE tab_id=?', [id]);
await pool.query('DELETE FROM project_tabs WHERE id=?', [id]);
}
// Ein Ordner mit allen Ebenen und den dort angepinnten Aufnahmen. Gelöschte
// (im Papierkorb liegende) Aufnahmen werden ausgeblendet, nicht entfernt: eine
// Wiederherstellung bringt den Pin ohne Zutun zurück.
export async function getProject(id) {
const [pr] = await pool.query('SELECT id, name, created_at FROM projects WHERE id=?', [id]);
if (!pr.length) return null;
const [tabs] = await pool.query(
'SELECT id, name, created_at FROM project_tabs WHERE project_id=? ORDER BY id', [id]);
const [pins] = await pool.query(`
SELECT pin.id AS pin_id, pin.tab_id, r.id, r.title, r.duration_ms, r.created_at,
r.status, r.analysis_status, r.summary
FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
WHERE pin.project_id=? AND r.deleted_at IS NULL
ORDER BY r.created_at DESC`, [id]);
return { ...pr[0], tabs, pins };
}
export async function pinRecording(projectId, tabId, recordingId) {
await pool.execute(
'INSERT IGNORE INTO project_pins (project_id, tab_id, recording_id) VALUES (?,?,?)',
[projectId, tabId || 0, recordingId]);
}
export async function unpinRecording(pinId) {
await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]);
}
// Wo hängt diese Aufnahme überall? Treibt die Markierung des 📌-Buttons in der
// Aufnahmenliste.
export async function listPinsForRecording(recId) {
const [rows] = await pool.query(`
SELECT pin.id AS pin_id, pin.project_id, p.name AS project_name,
pin.tab_id, t.name AS tab_name
FROM project_pins pin
JOIN projects p ON p.id = pin.project_id
LEFT JOIN project_tabs t ON t.id = pin.tab_id
WHERE pin.recording_id=? ORDER BY p.name, t.name`, [recId]);
return rows;
}
// Alle Pins auf einen Schlag (Liste: welche Aufnahme steckt in wie vielen
// Projekten) — spart einen Request pro Zeile.
export async function pinCounts() {
const [rows] = await pool.query(
'SELECT recording_id, COUNT(*) AS n FROM project_pins GROUP BY recording_id');
const m = {};
for (const r of rows) m[r.recording_id] = r.n;
return m;
}
// --- live speaker markers ---------------------------------------------------
@@ -331,7 +444,7 @@ export async function listTrash() {
export async function purgeRecording(id) {
const [rows] = await pool.query('SELECT mp3_file, wav_file FROM recordings WHERE id=?', [id]);
const files = rows[0] ? [rows[0].mp3_file, rows[0].wav_file].filter(Boolean) : [];
for (const t of ['markers', 'words', 'passages'])
for (const t of ['markers', 'words', 'passages', 'project_pins'])
await pool.query(`DELETE FROM ${t} WHERE recording_id=?`, [id]).catch(() => {});
await pool.query('DELETE FROM recordings WHERE id=?', [id]);
return files;
+57
View File
@@ -449,6 +449,63 @@ app.delete('/api/recordings/:id', wrap(async (req, res) => {
await db.deleteRecording(+req.params.id);
res.json({ ok: true, trashed: true, days: db.TRASH_DAYS });
}));
// --- Projektansicht -----------------------------------------------------------
// Ordner > Unterreiter > angepinnte Aufnahmen. Rein organisatorisch: ein Pin
// referenziert eine Aufnahme, er verschiebt oder kopiert nichts. Dieselbe
// Aufnahme darf in mehreren Projekten und Reitern hängen.
app.get('/api/projects', wrap(async (req, res) => res.json(await db.listProjects())));
app.post('/api/projects', wrap(async (req, res) => {
const name = String((req.body && req.body.name) || '').trim();
if (!name) return res.status(400).json({ error: 'name fehlt' });
res.json(await db.createProject(name));
}));
app.get('/api/projects/:id', wrap(async (req, res) => {
const p = await db.getProject(+req.params.id);
p ? res.json(p) : res.status(404).json({ error: 'not found' });
}));
app.patch('/api/projects/:id', wrap(async (req, res) => {
const name = String((req.body && req.body.name) || '').trim();
if (!name) return res.status(400).json({ error: 'name fehlt' });
await db.renameProject(+req.params.id, name);
res.json({ ok: true, id: +req.params.id, name });
}));
app.delete('/api/projects/:id', wrap(async (req, res) => {
await db.deleteProject(+req.params.id);
res.json({ ok: true });
}));
app.post('/api/projects/:id/tabs', wrap(async (req, res) => {
const name = String((req.body && req.body.name) || '').trim();
if (!name) return res.status(400).json({ error: 'name fehlt' });
res.json(await db.createProjectTab(+req.params.id, name));
}));
app.patch('/api/projects/:id/tabs/:tid', wrap(async (req, res) => {
const name = String((req.body && req.body.name) || '').trim();
if (!name) return res.status(400).json({ error: 'name fehlt' });
await db.renameProjectTab(+req.params.tid, name);
res.json({ ok: true, id: +req.params.tid, name });
}));
app.delete('/api/projects/:id/tabs/:tid', wrap(async (req, res) => {
await db.deleteProjectTab(+req.params.tid);
res.json({ ok: true });
}));
// tab_id fehlt / 0 = direkt auf der Ordner-Ebene anpinnen.
app.post('/api/projects/:id/pins', wrap(async (req, res) => {
const recId = +((req.body && req.body.recording_id) || 0);
if (!recId) return res.status(400).json({ error: 'recording_id fehlt' });
await db.pinRecording(+req.params.id, +((req.body && req.body.tab_id) || 0), recId);
res.json({ ok: true });
}));
app.delete('/api/projects/:id/pins/:pinId', wrap(async (req, res) => {
await db.unpinRecording(+req.params.pinId);
res.json({ ok: true });
}));
// Für den 📌-Button in der Aufnahmenliste: Zuordnungen einer Aufnahme bzw.
// die Pin-Zahl aller Aufnahmen auf einmal.
app.get('/api/recordings/:id/pins', wrap(async (req, res) => res.json(await db.listPinsForRecording(+req.params.id))));
app.get('/api/pins/counts', wrap(async (req, res) => res.json(await db.pinCounts())));
app.get('/api/trash', wrap(async (req, res) => res.json({ days: db.TRASH_DAYS, items: await db.listTrash() })));
app.post('/api/trash/:id/restore', wrap(async (req, res) => {
await db.restoreRecording(+req.params.id);
+325 -7
View File
@@ -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);
+49
View File
@@ -501,3 +501,52 @@ body.resizing { cursor: col-resize; user-select: none; }
color: var(--fg);
line-height: 2.2;
}
/* ---- Projektansicht ---------------------------------------------------------
Ordnergitter (Ebene 1) und darin eine Reiterleiste für die Unterreiter
(Ebene 2). Die Reiter sehen bewusst anders aus als die Kopfzeilen-Tabs, damit
klar bleibt: das ist eine Ebene INNERHALB eines Ordners. */
.proj-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.proj-head h3 { margin: 0; flex: 1; }
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin-top: 10px; }
.proj-card {
display: flex; align-items: center; gap: 10px; cursor: pointer;
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 10px; padding: 12px 14px; transition: border-color .1s, background .1s;
}
.proj-card:hover { border-color: var(--accent); }
.proj-card .grow { flex: 1; min-width: 0; }
.proj-card b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proj-ico { font-size: 22px; flex: none; }
.proj-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0 0; border-bottom: 1px solid var(--line); padding-bottom: 8px; }
.ptab {
background: transparent; border: 1px solid transparent; color: var(--muted);
border-radius: 8px 8px 0 0; padding: 6px 12px; cursor: pointer; font: inherit; font-size: .9rem;
}
.ptab:hover { color: var(--fg); background: var(--surface-2); }
.ptab.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.ptab.add { color: var(--muted); border-style: dashed; border-color: var(--line); }
.ptab.add:hover { color: var(--accent); border-color: var(--accent); }
.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; }
.proj-level { margin-top: 14px; }
.proj-level-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.proj-level-head .grow { flex: 1; }
.proj-level .item.rec { cursor: pointer; }
/* 📌 in der Aufnahmenliste: blass, solange die Aufnahme nirgends hängt. */
.pinbtn { opacity: .45; position: relative; }
.item.rec:hover .pinbtn { opacity: 1; }
.pinbtn.pinned { opacity: 1; border-color: var(--accent); color: var(--accent); }
.pin-n { font-size: 10px; font-weight: 700; margin-left: 2px; vertical-align: super; }
/* ---- Modal ---- */
.modal-back {
position: fixed; inset: 0; background: rgba(0, 0, 0, .5);
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px;
}
.modal { width: min(520px, 100%); max-height: 85vh; overflow: auto; margin: 0; }
.modal h3 { margin-top: 0; }
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.modal select[size] { height: auto; padding: 4px; }
.pin-current .item { padding: 6px 10px; }
+28
View File
@@ -3,6 +3,34 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-08-02 — Neuer Reiter „Projektansicht": Ordner, Unterreiter, angeheftete Aufnahmen
**Was:** Aufnahmen lassen sich jetzt in Projektordnern bündeln. Ein Ordner („Project X") nimmt direkt
Aufnahmen auf **und** kann Unterreiter („Projektrelevante Aufzeichnungen") enthalten, die ihrerseits
Aufnahmen aufnehmen. Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und öffnet
sie dort. Angeheftet wird auf zwei Wegen: aus dem Projekt heraus per Dropdown (mit Filterfeld) oder direkt
in der Aufnahmenliste über den neuen 📌-Knopf, der Ordner **und** Ebene abfragt, neue Ordner anlegen kann
und bestehende Anheftungen der Aufnahme zum Lösen anzeigt.
**Warum:** Die chronologische Aufnahmenliste beantwortet „wann war das", nicht „was gehört zu diesem
Projekt". Reihenfolge nach Datum ist für die tägliche Arbeit an mehreren Projekten die falsche Sortierung.
**Wie:** Drei Tabellen (`projects`, `project_tabs`, `project_pins`), angelegt in `ensureSchema` wie alle
anderen auch. Ein Pin ist eine reine Referenz — die Aufnahme selbst wird nie verschoben oder kopiert und
darf in mehreren Ordnern hängen.
Zwei Details, die sonst später weh getan hätten:
- `project_pins.tab_id` ist **`INT NOT NULL DEFAULT 0`** (0 = Ordner-Ebene), nicht `NULL`. MariaDB
schließt `NULL` von `UNIQUE`-Indizes aus; mit `NULL` ließe sich dieselbe Aufnahme mehrfach auf dieselbe
Ebene anheften. Mit `0` greift `UNIQUE (project_id, tab_id, recording_id)`, und `INSERT IGNORE` macht
das Anheften idempotent.
- `db.purgeRecording` räumt `project_pins` mit ab, und `getProject` blendet Aufnahmen im Papierkorb aus,
ohne ihren Pin zu löschen — nach dem Wiederherstellen ist die Aufnahme wieder im Ordner.
Dateien: `app/db.js`, `app/server.js`, `app/web/src/main.js`, `app/web/src/style.css`, neues Kapitel
[10 – Projektansicht](10-projektansicht.md). Board-Backups: `.bak-projects`.
## 2026-07-31 (spät) — Zwei Fehler aus dem Suche-Feature behoben
Beide beim Nachsehen aufgefallen, warum die GUI bei einer 29:32-Aufnahme **2 h 34 min Analysezeit** anzeigte.
+113
View File
@@ -0,0 +1,113 @@
# 10 — Projektansicht (Ordner, Unterreiter, angeheftete Aufnahmen)
Dazugekommen am 2026-08-02. Ein eigener Reiter **„Projektansicht"** neben „Aufnahmen": Aufnahmen lassen
sich in Projektordnern bündeln, ohne dass an der Aufnahme selbst etwas verändert wird.
## Modell in einem Satz
Ein **Pin ist eine Referenz, kein Besitz.** Die Aufnahme bleibt unverändert in der Aufnahmenliste, darf in
beliebig vielen Ordnern und Reitern hängen, und das Lösen einer Anheftung löscht nichts.
```mermaid
flowchart TD
P["📁 Ordner<br/>„Project X“"] --> L0["Ebene „Ordner“<br/>tab_id = 0"]
P --> T1["📑 Unterreiter<br/>„Projektrelevante Aufzeichnungen“"]
P --> T2["📑 Unterreiter<br/>„Angebote“"]
L0 --> R1["📌 #87 „1. Gespräch DFG – Projektstatusmeeting“"]
T1 --> R2["📌 #86"]
T1 --> R3["📌 #74"]
R1 -.->|"Klick"| REC["Reiter „Aufnahmen“<br/>öffnet die Aufnahme im Detailbereich"]
R2 -.->|"Klick"| REC
```
Zwei Ebenen, mehr nicht: Ordner und darin Unterreiter. Auf **beiden** kann angeheftet werden — der Ordner
selbst ist also nicht nur ein Behälter für Reiter.
## Datenbank
```sql
CREATE TABLE projects (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE project_tabs (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
name VARCHAR(190) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX proj_idx (project_id));
CREATE TABLE project_pins (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
tab_id INT NOT NULL DEFAULT 0, -- 0 = direkt im Ordner
recording_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_pin (project_id, tab_id, recording_id),
INDEX rec_idx (recording_id));
```
**Warum `tab_id = 0` statt `NULL`:** MySQL/MariaDB schließt `NULL`-Werte von einem `UNIQUE`-Index aus.
Mit `tab_id NULL` für „Ordner-Ebene" ließe sich dieselbe Aufnahme beliebig oft auf dieselbe Ebene
anheften, ohne dass der Index das verhindert. Mit `0` greift `UNIQUE` — das Anheften ist damit über
`INSERT IGNORE` idempotent, ein Doppelklick erzeugt keinen zweiten Eintrag.
Weitere Regeln:
- **Papierkorb:** `getProject` filtert auf `recordings.deleted_at IS NULL`. Eine gelöschte Aufnahme
verschwindet aus dem Ordner, ihr Pin bleibt aber stehen — nach dem Wiederherstellen ist sie wieder da,
ohne dass man erneut anheften muss.
- **Endgültiges Löschen:** `db.purgeRecording` räumt `project_pins` mit ab (in derselben Schleife wie
`markers`/`words`/`passages`), sonst blieben tote Verweise zurück.
- **Ordner/Reiter löschen** entfernt nur die Zeilen in `project_pins` — nie eine Aufnahme.
## Bedienung
Zwei Wege zum selben Ziel, weil beide Richtungen im Alltag vorkommen:
```mermaid
flowchart LR
subgraph A["Weg 1 — aus dem Projekt heraus"]
A1["Projektansicht → Ordner öffnen"] --> A2["Ebene wählen<br/>(Ordner oder Unterreiter)"]
A2 --> A3["📌 Aufnahme anheften"] --> A4["Dropdown mit allen Aufnahmen<br/>+ Filterfeld"]
end
subgraph B["Weg 2 — aus der Aufnahmenliste"]
B1["Aufnahmen → 📌 an der Zeile"] --> B2["Dialog: Ordner + Ebene wählen<br/>(inkl. „+ Neuen Ordner anlegen…“)"]
B2 --> B3["zeigt zugleich alle bestehenden<br/>Anheftungen dieser Aufnahme, löschbar"]
end
```
Der 📌-Knopf in der Aufnahmenliste trägt die Zahl der Projekte, in denen die Aufnahme hängt, und ist
farbig markiert, sobald sie irgendwo angeheftet ist (`GET /api/pins/counts` — ein Request für die ganze
Liste, nicht einer pro Zeile). Die Zahl steckt zusätzlich in der Listen-Signatur `state.lastListSig`, damit
eine neue Anheftung die Liste neu zeichnet, ohne die 5-Sekunden-Regel („keine Änderung → DOM nicht
anfassen") aufzuweichen.
Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und öffnet sie dort im
Detailbereich (`jumpToRecording`, dieselbe Mechanik wie der Sprung aus einem Suchtreffer, nur ohne
Zeitmarke).
## API
| Route | Zweck |
|---|---|
| `GET /api/projects` | Ordnerliste mit Anzahl Reiter und Anheftungen |
| `POST /api/projects` | Ordner anlegen `{name}` |
| `GET /api/projects/:id` | Ordner mit Reitern und aufgelösten Anheftungen |
| `PATCH /api/projects/:id` | Ordner umbenennen |
| `DELETE /api/projects/:id` | Ordner löschen (Reiter + Anheftungen mit) |
| `POST /api/projects/:id/tabs` | Unterreiter anlegen `{name}` |
| `PATCH /api/projects/:id/tabs/:tid` | Unterreiter umbenennen |
| `DELETE /api/projects/:id/tabs/:tid` | Unterreiter löschen |
| `POST /api/projects/:id/pins` | anheften `{recording_id, tab_id}` (`tab_id` fehlt/0 = Ordner-Ebene) |
| `DELETE /api/projects/:id/pins/:pinId` | Anheftung lösen |
| `GET /api/recordings/:id/pins` | wo hängt diese Aufnahme überall |
| `GET /api/pins/counts` | `{recording_id: anzahl}` für die ganze Liste |
## Dateien
`app/db.js` (Tabellen + CRUD), `app/server.js` (Routen), `app/web/src/main.js`
(`viewProjects`/`renderProjectList`/`renderProjectDetail`/`pinPickRecordingDialog`/`pinToProjectDialog`/
`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, Dialog).
Board-Backups: `.bak-projects`.