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.
167 lines
8.1 KiB
Markdown
167 lines
8.1 KiB
Markdown
# 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).
|