Files
Aime/docs/10-projektansicht.md
T
Sascha 3d628b4b96 feat(projekt): Anheftungen per Drag & Drop zwischen Unterreitern verschieben
Pin-Zeilen in der Ordneransicht sind draggable, die Reiter sind Ablageziele.
Ein Drop schickt PATCH /api/projects/:id/pins/:pinId {tab_id}; movePin schreibt
project_pins.tab_id um. Verschoben wird nur die Referenz.

- Zielebene hat die Aufnahme schon: Quell-Pin loeschen statt an UNIQUE uniq_pin
  scheitern (merged:true)
- Zielreiter serverseitig gegen den Ordner pruefen, sonst 404
- .ptab-n braucht pointer-events:none, sonst flackert die Ablagemarkierung

Verifiziert gegen die laufende App inkl. 404- und Merge-Faellen; Testdaten
zurueckgesetzt. Doku: Kapitel 10 + Changelog.
2026-08-14 14:52:14 +02:00

167 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
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<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 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,<br/>setzt dataTransfer (Firefox braucht das)
U->>G: dragover Zielreiter
G-->>U: Reiter gestrichelt markiert (.ptab.drop)<br/>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).