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.
This commit is contained in:
2026-08-14 14:52:14 +02:00
parent 2be938c91b
commit 3d628b4b96
6 changed files with 1842 additions and 1678 deletions
+27
View File
@@ -208,6 +208,33 @@ export async function pinRecording(projectId, tabId, recordingId) {
export async function unpinRecording(pinId) {
await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]);
}
// Einen Pin auf eine andere Ebene DESSELBEN Ordners schieben (Drag&Drop in der
// Projektansicht). Hängt die Aufnahme auf der Zielebene schon, entsteht kein
// zweiter Pin — der Quell-Pin verschwindet stattdessen (Zusammenführen statt
// Duplikat; ein UPDATE würde sonst an UNIQUE uniq_pin scheitern).
export async function movePin(pinId, projectId, tabId) {
const [rows] = await pool.execute(
'SELECT tab_id, recording_id FROM project_pins WHERE id=? AND project_id=?', [pinId, projectId]);
if (!rows.length) return null;
const src = rows[0];
const dst = tabId || 0;
// Zielreiter muss zu diesem Ordner gehören (0 = Ordner-Ebene, gibt es immer).
if (dst) {
const [t] = await pool.execute(
'SELECT id FROM project_tabs WHERE id=? AND project_id=?', [dst, projectId]);
if (!t.length) return null;
}
if (src.tab_id === dst) return { ok: true, moved: false, tab_id: dst };
const [dup] = await pool.execute(
'SELECT id FROM project_pins WHERE project_id=? AND tab_id=? AND recording_id=?',
[projectId, dst, src.recording_id]);
if (dup.length) {
await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]);
return { ok: true, moved: true, merged: true, tab_id: dst, pin_id: dup[0].id };
}
await pool.execute('UPDATE project_pins SET tab_id=? WHERE id=?', [dst, pinId]);
return { ok: true, moved: true, tab_id: dst, pin_id: pinId };
}
// Wo hängt diese Aufnahme überall? Treibt die Markierung des 📌-Buttons in der
// Aufnahmenliste.
export async function listPinsForRecording(recId) {
+8
View File
@@ -541,6 +541,14 @@ app.post('/api/projects/:id/pins', wrap(async (req, res) => {
await db.pinRecording(+req.params.id, +((req.body && req.body.tab_id) || 0), recId);
res.json({ ok: true });
}));
// Drag&Drop in der Projektansicht: Pin auf eine andere Ebene desselben Ordners
// ziehen. tab_id 0 = zurück auf die Ordner-Ebene.
app.patch('/api/projects/:id/pins/:pinId', wrap(async (req, res) => {
const tabId = +((req.body && req.body.tab_id) || 0);
const r = await db.movePin(+req.params.pinId, +req.params.id, tabId);
if (!r) return res.status(404).json({ error: 'Anheftung oder Zielreiter nicht gefunden' });
res.json(r);
}));
app.delete('/api/projects/:id/pins/:pinId', wrap(async (req, res) => {
await db.unpinRecording(+req.params.pinId);
res.json({ ok: true });
+45 -2
View File
@@ -647,14 +647,14 @@ async function renderProjectDetail(v) {
<div class="proj-level">
<div class="proj-level-head">
<b>${escapeHtml(levelName)}</b>
<span class="muted small">${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}</span>
<span class="muted small">${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}${p.tabs.length && pins.length ? ' · auf einen Reiter ziehen zum Verschieben' : ''}</span>
<span class="grow"></span>
<button id="ptabren" class="btn small">✏️ ${cur ? 'Reiter' : 'Ebene'} umbenennen</button>
${cur ? '<button id="ptabdel" class="btn small danger">🗑 Reiter löschen</button>' : ''}
<button id="ppin" class="btn primary small">📌 Aufnahme anheften</button>
</div>
<div id="ppins" class="list">${pins.length ? pins.map(r => `
<div class="item rec" data-open="${r.id}">
<div class="item rec" data-open="${r.id}" data-pin="${r.pin_id}" draggable="true">
<div class="grow">
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b> ${analysisBadge(r.analysis_status)}
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}</div>
@@ -711,6 +711,49 @@ async function renderProjectDetail(v) {
toast('Anheftung gelöst');
renderProjectDetail(v);
});
bindPinDrag(v, p, cur, rootName);
}
// Drag&Drop innerhalb eines Ordners: eine angeheftete Aufnahme auf einen
// anderen Reiter ziehen verschiebt den Pin dorthin. Verschoben wird nur die
// Referenz — die Aufnahme selbst bleibt, wo sie ist.
function bindPinDrag(v, p, cur, rootName) {
let dragPin = null;
const clearMarks = () => v.querySelectorAll('.ptab.drop').forEach(t => t.classList.remove('drop'));
v.querySelectorAll('#ppins .item.rec').forEach(it => {
it.ondragstart = (e) => {
dragPin = it.dataset.pin;
it.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Ohne gesetzte Daten startet Firefox den Drag gar nicht erst.
e.dataTransfer.setData('text/plain', it.dataset.pin);
};
it.ondragend = () => { dragPin = null; it.classList.remove('dragging'); clearMarks(); };
});
v.querySelectorAll('.ptab[data-ptab]').forEach(t => {
const target = +t.dataset.ptab;
t.ondragover = (e) => {
if (dragPin == null || target === cur) return; // eigener Reiter = kein Ziel
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
t.classList.add('drop');
};
t.ondragleave = () => t.classList.remove('drop');
t.ondrop = async (e) => {
e.preventDefault();
clearMarks();
const pinId = dragPin || e.dataTransfer.getData('text/plain');
dragPin = null;
if (!pinId || target === cur) return;
const name = target ? (p.tabs.find(x => x.id === target) || {}).name : rootName;
try {
const r = await api.patch(`/api/projects/${p.id}/pins/${pinId}`, { tab_id: target });
toast(r.merged ? `Hängt dort bereits — Anheftung zusammengeführt`
: `Verschoben nach „${name}"`);
} catch (err) { toast(err.message, true); }
renderProjectDetail(v);
};
});
}
// Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften.
+9 -1
View File
@@ -527,12 +527,20 @@ body.resizing { cursor: col-resize; user-select: none; }
.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; }
/* pointer-events aus: sonst feuert beim Ueberfahren des Zaehlers ein
dragleave am Reiter und die Ablagemarkierung flackert. */
.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; pointer-events: none; }
/* Reiter als Ablageziel, waehrend ein Pin darueber gezogen wird. */
.ptab.drop {
color: var(--accent); background: var(--accent-soft);
border-color: var(--accent); border-style: dashed;
}
.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; }
.proj-level .item.rec.dragging { opacity: .45; border-style: dashed; }
/* Stecknadel in der Aufnahmenliste: blass, solange die Aufnahme nirgends
hängt — angeheftet wird sie gruen (dieselbe Farbe wie „KI ✓“, also
+33
View File
@@ -3,6 +3,39 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-08-14 — Angeheftete Aufnahmen per Drag & Drop zwischen Unterreitern verschieben
**Wunsch:** eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von
`DFG/Projektleiter Runden` nach `DFG/INF-Projektboard`. Bisher ging das nur über Lösen + neu Anheften,
also zwei Dialoge für eine Bewegung.
**Umsetzung:** die Pin-Zeilen in der Ordneransicht sind `draggable`, die Reiter der Reiterleiste sind
Ablageziele (`bindPinDrag` in `main.js`). Ein Drop schickt `PATCH /api/projects/:id/pins/:pinId {tab_id}`;
`db.movePin` schreibt `project_pins.tab_id` um. Verschoben wird ausschließlich die Referenz — an der
Aufnahme, ihrer Analyse oder ihren übrigen Anheftungen ändert sich nichts.
Drei Punkte, die beim Bauen aufgefallen sind:
- Hängt die Aufnahme auf der Zielebene bereits, würde das `UPDATE` an `UNIQUE uniq_pin` scheitern.
`movePin` löscht dann den Quell-Pin (**Zusammenführen statt Fehler**) und meldet `{merged:true}`.
- Der Zielreiter wird serverseitig gegen den Ordner geprüft (`project_tabs.project_id`), sonst könnte ein
veralteter Browser-Tab einen Pin in ein fremdes Projekt schieben → sonst 404.
- `.ptab-n` (Zähler im Reiter) braucht `pointer-events: none`, sonst feuert beim Überfahren des Zählers
ein `dragleave` und die Ablagemarkierung flackert.
**Verifiziert** direkt gegen die laufende App (Ordner „DFG", Pin #12): Verschieben Reiter 2 → 3 → 2 jeweils
`{"moved":true}` und `tab_id` in der DB entsprechend; fremder Reiter (999) und fremdes Projekt (2) je
`404 {"error":"Anheftung oder Zielreiter nicht gefunden"}`; gleicher Reiter `{"moved":false}` ohne
Schreibzugriff; Zusammenführen getestet mit Aufnahme #109 (zwei Pins → einer). Testdaten danach auf den
Ausgangsstand zurückgesetzt. `vite build` sauber, `systemctl restart callrec` erst nach Prüfung auf
`analysis_status='pending'` (keine laufende Analyse, keine laufende Aufnahme).
Dateien: `app/db.js` (`movePin`), `app/server.js` (`PATCH …/pins/:pinId`), `app/web/src/main.js`
(`bindPinDrag`, `draggable` an der Pin-Zeile, Hinweistext in der Ebenen-Kopfzeile),
`app/web/src/style.css` (`.ptab.drop`, `.item.rec.dragging`, `.ptab-n`).
Board-Backups: `.bak-movepin` (db.js, server.js, web/src/main.js, web/src/style.css, web/dist).
Kapitel 10 (neuer Abschnitt „Verschieben per Drag & Drop" + Sequenzdiagramm + API-Tabelle).
## 2026-08-10 — Recorder überlebt das Neuladen (GUI holt den Zustand vom Server)
**Symptom:** während einer laufenden Aufnahme klickte der Nutzer in eine andere Aufnahme; danach stand der
+49 -4
View File
@@ -96,6 +96,49 @@ Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und
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 |
@@ -109,13 +152,15 @@ Zeitmarke).
| `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), `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`.
`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).