# 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. Die Ordner-Ebene ist genauso benennbar wie ein Unterreiter (Vorgabe: „Ordner"). Ihr Name steht aber in `projects.root_tab_name` und nicht als Zeile in `project_tabs`, weil sie kein echter Reiter ist: es gibt sie immer, sie hat keine eigene id und lässt sich nicht löschen. `PATCH …/tabs/0` schreibt deshalb ins Projekt; ein leerer Name setzt auf „Ordner" zurück. ## Datenbank ```sql CREATE TABLE projects ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(190) NOT NULL, root_tab_name VARCHAR(190) NULL, -- Beschriftung der Ordner-Ebene, NULL = „Ordner" 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 Nadel-Knopf in der Aufnahmenliste trägt die Zahl der Projekte, in denen die Aufnahme hängt, und wird **grün** (`--ok`, dieselbe Farbe wie „KI ✓"), sobald sie irgendwo angeheftet ist — sonst bleibt er blass wie die übrigen Zeilenknöpfe (`GET /api/pins/counts` — ein Request für die ganze Liste, nicht einer pro Zeile). Die Nadel ist deshalb ein **SVG mit `currentColor` (`ICON_PIN`) und kein 📌-Emoji**: ein Farb-Emoji ist in jedem Zustand rot und lässt sich per CSS nicht umfärben. 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). ## Verschieben per Drag & Drop (2026-08-14) Eine angeheftete Aufnahme lässt sich in der Ordneransicht auf einen **anderen Reiter der Reiterleiste ziehen** und wechselt damit die Ebene — z. B. von „Projektleiter Runden" nach „INF-Projektboard". Verschoben wird nur der Pin (`project_pins.tab_id`); die Aufnahme selbst wird nicht angefasst. ```mermaid sequenceDiagram participant U as Nutzer participant G as GUI (main.js) participant S as server.js participant D as MariaDB U->>G: dragstart auf 📌-Zeile Note over G: merkt pin_id,
setzt dataTransfer (Firefox braucht das) U->>G: dragover Zielreiter G-->>U: Reiter gestrichelt markiert (.ptab.drop)
eigener Reiter = kein Ziel U->>G: drop G->>S: PATCH /api/projects/:id/pins/:pinId {tab_id} S->>D: db.movePin() alt Zielreiter gehört nicht zum Ordner / Pin unbekannt D-->>S: null S-->>G: 404 else Aufnahme hängt dort schon D->>D: DELETE Quell-Pin (Zusammenführen) S-->>G: {moved:true, merged:true} else Normalfall D->>D: UPDATE project_pins SET tab_id S-->>G: {moved:true} end G->>G: renderProjectDetail() neu zeichnen ``` Drei Details, die nicht offensichtlich sind: - **Zusammenführen statt Duplikat:** hängt dieselbe Aufnahme auf der Zielebene bereits, würde das `UPDATE` an `UNIQUE uniq_pin` scheitern. `movePin` löscht in diesem Fall den Quell-Pin, statt einen Fehler zu werfen — im Ergebnis ist die Aufnahme genau einmal auf der Zielebene, was der Nutzer erwartet hat. - **Ziel wird serverseitig geprüft:** `tab_id` muss ein Reiter *desselben* Ordners sein (oder 0 für die Ordner-Ebene), sonst 404. Ein veralteter Reiter im offenen Browser-Tab kann so keinen Pin in ein fremdes Projekt schieben. - **`.ptab-n` (der Zähler im Reiter) bekommt `pointer-events: none`.** Sonst feuert beim Überfahren des Zählers ein `dragleave` am Reiter und die Ablagemarkierung flackert. ## 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; `:tid = 0` benennt die Ordner-Ebene um (leerer Name = zurück auf „Ordner") | | `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) | | `PATCH /api/projects/:id/pins/:pinId` | Anheftung auf eine andere Ebene desselben Ordners verschieben `{tab_id}` (Drag & Drop); 404 bei fremdem Reiter/Ordner, `{merged:true}` wenn dort schon vorhanden | | `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, `movePin`), `app/server.js` (Routen), `app/web/src/main.js` (`viewProjects`/`renderProjectList`/`renderProjectDetail`/`bindPinDrag`/`pinPickRecordingDialog`/ `pinToProjectDialog`/`openModal`), `app/web/src/style.css` (Ordnergitter, Reiterleiste, `.ptab.drop`, `.item.rec.dragging`, Dialog). Board-Backups: `.bak-projects` (Grundfunktion), `.bak-movepin` (Drag & Drop).