feat(gui): Ordner-Ebene eines Projekts umbenennbar
Der erste Reiter hieß fest "Ordner" und trägt jetzt einen frei wählbaren Namen. Er steht in der neuen Spalte projects.root_tab_name (NULL = "Ordner") statt als Zeile in project_tabs: die Ordner-Ebene ist kein echter Reiter -- es gibt sie immer, sie hat keine eigene id und ist nicht löschbar. Eine eigene Zeile hätte eine id gebraucht, die in project_pins.tab_id landen müsste, womit die "0 = Ordner-Ebene"-Regel und damit der UNIQUE-Index gefallen wären. PATCH /api/projects/:id/tabs/0 fängt die 0 ab und schreibt ins Projekt; leerer Name setzt auf "Ordner" zurück. Ebenen-Auswahl im Pin-Dialog und die Liste bestehender Anheftungen zeigen den Namen mit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
772385713e
commit
d0e02eb976
@@ -89,6 +89,10 @@ export async function ensureSchema() {
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(190) NOT NULL,
|
name VARCHAR(190) NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
||||||
|
// Beschriftung der Ordner-Ebene (tab_id 0). Die liegt am Projekt statt in
|
||||||
|
// project_tabs, weil sie keine echte Reiter-Zeile ist — es gibt sie immer,
|
||||||
|
// sie lässt sich nicht löschen und hat keine eigene id. NULL = „Ordner".
|
||||||
|
await pool.query('ALTER TABLE projects ADD COLUMN IF NOT EXISTS root_tab_name VARCHAR(190) NULL');
|
||||||
await pool.query(`CREATE TABLE IF NOT EXISTS project_tabs (
|
await pool.query(`CREATE TABLE IF NOT EXISTS project_tabs (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
project_id INT NOT NULL,
|
project_id INT NOT NULL,
|
||||||
@@ -113,7 +117,7 @@ export async function ensureSchema() {
|
|||||||
// der (nicht gelöschten) Pins über alle Ebenen.
|
// der (nicht gelöschten) Pins über alle Ebenen.
|
||||||
export async function listProjects() {
|
export async function listProjects() {
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT p.id, p.name, p.created_at,
|
SELECT p.id, p.name, p.root_tab_name, p.created_at,
|
||||||
(SELECT COUNT(*) FROM project_tabs t WHERE t.project_id = p.id) AS tabs,
|
(SELECT COUNT(*) FROM project_tabs t WHERE t.project_id = p.id) AS tabs,
|
||||||
(SELECT COUNT(*) FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
|
(SELECT COUNT(*) FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
|
||||||
WHERE pin.project_id = p.id AND r.deleted_at IS NULL) AS pins
|
WHERE pin.project_id = p.id AND r.deleted_at IS NULL) AS pins
|
||||||
@@ -128,6 +132,12 @@ export async function createProject(name) {
|
|||||||
export async function renameProject(id, name) {
|
export async function renameProject(id, name) {
|
||||||
await pool.execute('UPDATE projects SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
|
await pool.execute('UPDATE projects SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
|
||||||
}
|
}
|
||||||
|
// Ordner-Ebene umbenennen. Leerer Name setzt auf die Vorgabe „Ordner" zurück.
|
||||||
|
export async function renameProjectRoot(id, name) {
|
||||||
|
const n = String(name || '').trim().slice(0, 190);
|
||||||
|
await pool.execute('UPDATE projects SET root_tab_name=? WHERE id=?', [n || null, id]);
|
||||||
|
return n || null;
|
||||||
|
}
|
||||||
// Der Ordner verschwindet mitsamt Reitern und Pins — die Aufnahmen selbst
|
// Der Ordner verschwindet mitsamt Reitern und Pins — die Aufnahmen selbst
|
||||||
// bleiben unberührt (ein Pin ist nur eine Referenz).
|
// bleiben unberührt (ein Pin ist nur eine Referenz).
|
||||||
export async function deleteProject(id) {
|
export async function deleteProject(id) {
|
||||||
@@ -153,7 +163,7 @@ export async function deleteProjectTab(id) {
|
|||||||
// (im Papierkorb liegende) Aufnahmen werden ausgeblendet, nicht entfernt: eine
|
// (im Papierkorb liegende) Aufnahmen werden ausgeblendet, nicht entfernt: eine
|
||||||
// Wiederherstellung bringt den Pin ohne Zutun zurück.
|
// Wiederherstellung bringt den Pin ohne Zutun zurück.
|
||||||
export async function getProject(id) {
|
export async function getProject(id) {
|
||||||
const [pr] = await pool.query('SELECT id, name, created_at FROM projects WHERE id=?', [id]);
|
const [pr] = await pool.query('SELECT id, name, root_tab_name, created_at FROM projects WHERE id=?', [id]);
|
||||||
if (!pr.length) return null;
|
if (!pr.length) return null;
|
||||||
const [tabs] = await pool.query(
|
const [tabs] = await pool.query(
|
||||||
'SELECT id, name, created_at FROM project_tabs WHERE project_id=? ORDER BY id', [id]);
|
'SELECT id, name, created_at FROM project_tabs WHERE project_id=? ORDER BY id', [id]);
|
||||||
@@ -179,7 +189,7 @@ export async function unpinRecording(pinId) {
|
|||||||
export async function listPinsForRecording(recId) {
|
export async function listPinsForRecording(recId) {
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT pin.id AS pin_id, pin.project_id, p.name AS project_name,
|
SELECT pin.id AS pin_id, pin.project_id, p.name AS project_name,
|
||||||
pin.tab_id, t.name AS tab_name
|
pin.tab_id, COALESCE(t.name, p.root_tab_name) AS tab_name
|
||||||
FROM project_pins pin
|
FROM project_pins pin
|
||||||
JOIN projects p ON p.id = pin.project_id
|
JOIN projects p ON p.id = pin.project_id
|
||||||
LEFT JOIN project_tabs t ON t.id = pin.tab_id
|
LEFT JOIN project_tabs t ON t.id = pin.tab_id
|
||||||
|
|||||||
@@ -479,8 +479,15 @@ app.post('/api/projects/:id/tabs', wrap(async (req, res) => {
|
|||||||
if (!name) return res.status(400).json({ error: 'name fehlt' });
|
if (!name) return res.status(400).json({ error: 'name fehlt' });
|
||||||
res.json(await db.createProjectTab(+req.params.id, name));
|
res.json(await db.createProjectTab(+req.params.id, name));
|
||||||
}));
|
}));
|
||||||
|
// tid 0 = die Ordner-Ebene. Die ist keine Zeile in project_tabs (es gibt sie
|
||||||
|
// immer, sie lässt sich nicht löschen), ihre Beschriftung hängt am Projekt.
|
||||||
|
// Leerer Name setzt sie auf die Vorgabe „Ordner" zurück.
|
||||||
app.patch('/api/projects/:id/tabs/:tid', wrap(async (req, res) => {
|
app.patch('/api/projects/:id/tabs/:tid', wrap(async (req, res) => {
|
||||||
const name = String((req.body && req.body.name) || '').trim();
|
const name = String((req.body && req.body.name) || '').trim();
|
||||||
|
if (+req.params.tid === 0) {
|
||||||
|
const n = await db.renameProjectRoot(+req.params.id, name);
|
||||||
|
return res.json({ ok: true, id: 0, name: n });
|
||||||
|
}
|
||||||
if (!name) return res.status(400).json({ error: 'name fehlt' });
|
if (!name) return res.status(400).json({ error: 'name fehlt' });
|
||||||
await db.renameProjectTab(+req.params.tid, name);
|
await db.renameProjectTab(+req.params.tid, name);
|
||||||
res.json({ ok: true, id: +req.params.tid, name });
|
res.json({ ok: true, id: +req.params.tid, name });
|
||||||
|
|||||||
+12
-7
@@ -575,7 +575,9 @@ async function renderProjectDetail(v) {
|
|||||||
if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0;
|
if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0;
|
||||||
const cur = state.projectTabId;
|
const cur = state.projectTabId;
|
||||||
const pins = p.pins.filter(x => x.tab_id === cur);
|
const pins = p.pins.filter(x => x.tab_id === cur);
|
||||||
const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : p.name;
|
// Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner".
|
||||||
|
const rootName = p.root_tab_name || 'Ordner';
|
||||||
|
const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName;
|
||||||
v.innerHTML = `
|
v.innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="proj-head">
|
<div class="proj-head">
|
||||||
@@ -584,7 +586,8 @@ async function renderProjectDetail(v) {
|
|||||||
<button id="prename" class="btn icon" title="Ordner umbenennen">✏️</button>
|
<button id="prename" class="btn icon" title="Ordner umbenennen">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="proj-tabs">
|
<div class="proj-tabs">
|
||||||
<button class="ptab ${cur === 0 ? 'active' : ''}" data-ptab="0">Ordner</button>
|
<button class="ptab ${cur === 0 ? 'active' : ''}" data-ptab="0">${escapeHtml(rootName)}
|
||||||
|
<span class="ptab-n">${p.pins.filter(x => x.tab_id === 0).length}</span></button>
|
||||||
${p.tabs.map(t => `<button class="ptab ${cur === t.id ? 'active' : ''}" data-ptab="${t.id}">${escapeHtml(t.name)}
|
${p.tabs.map(t => `<button class="ptab ${cur === t.id ? 'active' : ''}" data-ptab="${t.id}">${escapeHtml(t.name)}
|
||||||
<span class="ptab-n">${p.pins.filter(x => x.tab_id === t.id).length}</span></button>`).join('')}
|
<span class="ptab-n">${p.pins.filter(x => x.tab_id === t.id).length}</span></button>`).join('')}
|
||||||
<button id="ptabnew" class="ptab add" title="Unterreiter anlegen">+ Reiter</button>
|
<button id="ptabnew" class="ptab add" title="Unterreiter anlegen">+ Reiter</button>
|
||||||
@@ -594,8 +597,8 @@ async function renderProjectDetail(v) {
|
|||||||
<b>${escapeHtml(levelName)}</b>
|
<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'}</span>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
${cur ? `<button id="ptabren" class="btn small">✏️ Reiter umbenennen</button>
|
<button id="ptabren" class="btn small">✏️ ${cur ? 'Reiter' : 'Ebene'} umbenennen</button>
|
||||||
<button id="ptabdel" class="btn small danger">🗑 Reiter löschen</button>` : ''}
|
${cur ? '<button id="ptabdel" class="btn small danger">🗑 Reiter löschen</button>' : ''}
|
||||||
<button id="ppin" class="btn primary small">📌 Aufnahme anheften</button>
|
<button id="ppin" class="btn primary small">📌 Aufnahme anheften</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="ppins" class="list">${pins.length ? pins.map(r => `
|
<div id="ppins" class="list">${pins.length ? pins.map(r => `
|
||||||
@@ -630,8 +633,10 @@ async function renderProjectDetail(v) {
|
|||||||
};
|
};
|
||||||
const renBtn = v.querySelector('#ptabren');
|
const renBtn = v.querySelector('#ptabren');
|
||||||
if (renBtn) renBtn.onclick = async () => {
|
if (renBtn) renBtn.onclick = async () => {
|
||||||
const name = prompt('Unterreiter umbenennen:', levelName);
|
// Auf der Ordner-Ebene ist ein leeres Feld erlaubt: es setzt die
|
||||||
if (name === null || !name.trim()) return;
|
// Beschriftung auf die Vorgabe „Ordner" zurück.
|
||||||
|
const name = prompt(cur ? 'Unterreiter umbenennen:' : 'Ordner-Ebene umbenennen (leer = „Ordner"):', levelName);
|
||||||
|
if (name === null || (cur && !name.trim())) return;
|
||||||
await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() });
|
await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() });
|
||||||
renderProjectDetail(v);
|
renderProjectDetail(v);
|
||||||
};
|
};
|
||||||
@@ -719,7 +724,7 @@ async function pinToProjectDialog(rec, done) {
|
|||||||
const loadTabs = async () => {
|
const loadTabs = async () => {
|
||||||
if (pp.value === 'new') { pt.innerHTML = '<option value="0">Ordner-Ebene</option>'; return; }
|
if (pp.value === 'new') { pt.innerHTML = '<option value="0">Ordner-Ebene</option>'; return; }
|
||||||
const p = await api.get('/api/projects/' + pp.value);
|
const p = await api.get('/api/projects/' + pp.value);
|
||||||
pt.innerHTML = '<option value="0">Ordner-Ebene</option>' +
|
pt.innerHTML = `<option value="0">${escapeHtml(p.root_tab_name || 'Ordner-Ebene')}</option>` +
|
||||||
p.tabs.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
|
p.tabs.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
|
||||||
};
|
};
|
||||||
pp.onchange = async () => {
|
pp.onchange = async () => {
|
||||||
|
|||||||
@@ -3,6 +3,24 @@
|
|||||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||||
Was, Warum, betroffene Dateien/Backups).
|
Was, Warum, betroffene Dateien/Backups).
|
||||||
|
|
||||||
|
## 2026-08-02 (nachgereicht) — Ordner-Ebene umbenennbar
|
||||||
|
|
||||||
|
Der erste Reiter eines Projekts hieß fest „Ordner". Er trägt jetzt einen frei wählbaren Namen, genau wie
|
||||||
|
die Unterreiter.
|
||||||
|
|
||||||
|
Der Name steht in der **neuen Spalte `projects.root_tab_name`** (NULL = „Ordner") und nicht als Zeile in
|
||||||
|
`project_tabs`: die Ordner-Ebene ist kein echter Reiter — es gibt sie immer, sie hat keine eigene id und
|
||||||
|
lässt sich nicht löschen. Eine Zeile in `project_tabs` hätte für sie eine id gebraucht, die dann in
|
||||||
|
`project_pins.tab_id` auftauchen müsste, womit die „0 = Ordner-Ebene"-Regel und mit ihr der
|
||||||
|
`UNIQUE`-Index gefallen wären.
|
||||||
|
|
||||||
|
`PATCH /api/projects/:id/tabs/0` fängt die 0 ab und schreibt ins Projekt; ein leerer Name setzt zurück auf
|
||||||
|
„Ordner". Auch die Ebenen-Auswahl im 📌-Dialog und die Liste bestehender Anheftungen zeigen den Namen
|
||||||
|
(`listPinsForRecording` per `COALESCE(t.name, p.root_tab_name)`).
|
||||||
|
|
||||||
|
Dateien: `app/db.js`, `app/server.js`, `app/web/src/main.js`, Kapitel
|
||||||
|
[10 – Projektansicht](10-projektansicht.md).
|
||||||
|
|
||||||
## 2026-08-02 — Neuer Reiter „Projektansicht": Ordner, Unterreiter, angeheftete Aufnahmen
|
## 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
|
**Was:** Aufnahmen lassen sich jetzt in Projektordnern bündeln. Ein Ordner („Project X") nimmt direkt
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ flowchart TD
|
|||||||
Zwei Ebenen, mehr nicht: Ordner und darin Unterreiter. Auf **beiden** kann angeheftet werden — der Ordner
|
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.
|
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
|
## Datenbank
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE projects (
|
CREATE TABLE projects (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(190) NOT NULL,
|
name VARCHAR(190) NOT NULL,
|
||||||
|
root_tab_name VARCHAR(190) NULL, -- Beschriftung der Ordner-Ebene, NULL = „Ordner"
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
CREATE TABLE project_tabs (
|
CREATE TABLE project_tabs (
|
||||||
@@ -98,7 +104,7 @@ Zeitmarke).
|
|||||||
| `PATCH /api/projects/:id` | Ordner umbenennen |
|
| `PATCH /api/projects/:id` | Ordner umbenennen |
|
||||||
| `DELETE /api/projects/:id` | Ordner löschen (Reiter + Anheftungen mit) |
|
| `DELETE /api/projects/:id` | Ordner löschen (Reiter + Anheftungen mit) |
|
||||||
| `POST /api/projects/:id/tabs` | Unterreiter anlegen `{name}` |
|
| `POST /api/projects/:id/tabs` | Unterreiter anlegen `{name}` |
|
||||||
| `PATCH /api/projects/:id/tabs/:tid` | Unterreiter umbenennen |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `DELETE /api/projects/:id/pins/:pinId` | Anheftung lösen |
|
||||||
|
|||||||
Reference in New Issue
Block a user