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
+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;