`, {
+ 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() {
` : ''}
+
`; }).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`.