From 772385713e8789736f267722a49d71b285919850 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Sun, 2 Aug 2026 23:05:07 +0200 Subject: [PATCH] feat(gui): Projektansicht mit Ordnern, Unterreitern und angehefteten Aufnahmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 1 + app/db.js | 115 ++++++++++++- app/server.js | 57 +++++++ app/web/src/main.js | 332 +++++++++++++++++++++++++++++++++++++- app/web/src/style.css | 51 +++++- docs/08-changelog.md | 28 ++++ docs/10-projektansicht.md | 113 +++++++++++++ 7 files changed, 688 insertions(+), 9 deletions(-) create mode 100644 docs/10-projektansicht.md diff --git a/README.md b/README.md index 2c682ac..94539a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/db.js b/app/db.js index 77179ab..35af3e2 100644 --- a/app/db.js +++ b/app/db.js @@ -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; diff --git a/app/server.js b/app/server.js index c91de4e..a87bc76 100644 --- a/app/server.js +++ b/app/server.js @@ -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); diff --git a/app/web/src/main.js b/app/web/src/main.js index aeaa779..63fe622 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -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() { 🏷 ${state.markers.length}` : ''} @@ -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 = ` + `; + 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 = ` +
+
+

Projekte

+ +
+
Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
+
Lade…
+
`; + 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 = '
' + e.message + '
'; return; } + if (!ps.length) { listEl.innerHTML = '
Noch keine Projekte. Oben einen Ordner anlegen.
'; return; } + listEl.innerHTML = ps.map(p => ` +
+
📁
+
+ ${escapeHtml(p.name)} +
${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter
+
+ + +
`).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 = '
Lade…
'; + let p; + try { p = await api.get('/api/projects/' + state.projectId); } + catch (e) { v.innerHTML = '
' + e.message + '
'; 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 = ` +
+
+ +

📁 ${escapeHtml(p.name)}

+ +
+
+ + ${p.tabs.map(t => ``).join('')} + +
+
+
+ ${escapeHtml(levelName)} + ${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'} + + ${cur ? ` + ` : ''} + +
+
${pins.length ? pins.map(r => ` +
+
+ ${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)} +
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
+ ${r.summary ? `
${escapeHtml(r.summary)}
` : ''} +
+ +
`).join('') + : '
Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.
'}
+
+
`; + 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 => ``; + openModal('Aufnahme anheften', ` + + + + `, { + 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 ? ` + +
${pins.map(p => ` +
📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}
+
`).join('')}
` : ''; + const m = openModal(`„${escapeHtml(title)}" anheften`, ` + + + + +
${pinsHtml()}
`, { + 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 = ''; return; } + const p = await api.get('/api/projects/' + pp.value); + pt.innerHTML = '' + + p.tabs.map(t => ``).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', ``); + 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 = '
' + e.message + '
'; 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() {
${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 : ''}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''} + `; }).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); diff --git a/app/web/src/style.css b/app/web/src/style.css index bae6e52..362879c 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -500,4 +500,53 @@ body.resizing { cursor: col-resize; user-select: none; } font-weight: 700; color: var(--fg); line-height: 2.2; -} \ No newline at end of file +} +/* ---- 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; } diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 2fa7976..0682767 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -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. diff --git a/docs/10-projektansicht.md b/docs/10-projektansicht.md new file mode 100644 index 0000000..34bf962 --- /dev/null +++ b/docs/10-projektansicht.md @@ -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
„Project X“"] --> L0["Ebene „Ordner“
tab_id = 0"] + P --> T1["📑 Unterreiter
„Projektrelevante Aufzeichnungen“"] + P --> T2["📑 Unterreiter
„Angebote“"] + L0 --> R1["📌 #87 „1. Gespräch DFG – Projektstatusmeeting“"] + T1 --> R2["📌 #86"] + T1 --> R3["📌 #74"] + R1 -.->|"Klick"| REC["Reiter „Aufnahmen“
ö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
(Ordner oder Unterreiter)"] + A2 --> A3["📌 Aufnahme anheften"] --> A4["Dropdown mit allen Aufnahmen
+ Filterfeld"] + end + subgraph B["Weg 2 — aus der Aufnahmenliste"] + B1["Aufnahmen → 📌 an der Zeile"] --> B2["Dialog: Ordner + Ebene wählen
(inkl. „+ Neuen Ordner anlegen…“)"] + B2 --> B3["zeigt zugleich alle bestehenden
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`.