BUG-05: Speichern-Button weg, Save-Status-Indikator mit States

Der 'Speichern'-Button war redundant — debouncedSave() lief schon
bei jedem Drop/Resize/Add/Delete. Klick auf den Button machte nur
einen sofortigen POST statt 500ms-debounce. Verwirrend statt hilfreich.

Fix:
- Speichern-Button aus dem Toolbar entfernt.
- Stattdessen: <div id='saveIndicator' data-state='idle'> mit .save-dot
  und .save-label — rechtsbündig im Toolbar (margin-left:auto).
- States: idle | pending | saving | error (CSS-Data-Attr-basiert).
  - idle: grüner Punkt, 'Gespeichert'
  - pending: warn-gelber Punkt pulsiert, 'Ungespeichert…' (debouncedSave-Start)
  - saving: info-blauer Punkt pulsiert schneller, 'Speichern…' (saveLayout-Start)
  - error: alert-roter Punkt, 'Fehler: ...' (j.ok=false)
- setSaveState(state, label?) als zentraler Helper.
- saveLayout() macht jetzt success-toast weg — der Indicator reicht.
- Fehlerfall: error-toast bleibt (kritisch, separat sichtbar).

Vorteile:
- Toolbar ist aufgeräumter, +Button ist eindeutig der primäre Action.
- User sieht Save-Status ohne Klick auf irgendwas — visuelles Feedback
  für Auto-Save.
- Fehler-State zeigt sofort 'Fehler: ...' mit Details.

Beweis: tests/test_save_indicator.js (11/11 grün)
  1. Speichern-Button entfernt
  2. Save-Indicator #saveIndicator vorhanden
  3. setSaveState() Funktion definiert
  4. setSaveState setzt dataset.state
  5. debouncedSave setzt pending-Status
  6. saveLayout setzt saving am Anfang
  7. saveLayout setzt idle bei Erfolg
  8. saveLayout setzt error bei j.ok=false
  9. CSS für alle 4 Indicator-States
 10. Kein success-toast in saveLayout mehr
 11. Save-Indicator rechtsbündig (margin-left:auto)

Closes #7
This commit is contained in:
ki
2026-08-29 17:47:23 +04:00
parent 5f74238e0e
commit d0cc69410f
2 changed files with 155 additions and 14 deletions
+81 -14
View File
@@ -330,6 +330,38 @@
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin-bottom: 16px;
}
/* BUG-05: Save-Status-Indikator (ersetzt den redundanten Speichern-Button) */
.save-indicator {
display: inline-flex; align-items: center; gap: 6px;
margin-left: auto; /* rechtsbündig im toolbar */
padding: 6px 10px;
border-radius: 6px;
font-size: 0.82em;
color: var(--fg-muted);
transition: color 0.15s ease;
}
.save-indicator .save-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
transition: background 0.15s ease, box-shadow 0.15s ease;
}
/* Zustände */
.save-indicator[data-state="idle"] .save-dot { background: var(--ok); }
.save-indicator[data-state="pending"] .save-dot {
background: var(--warn); animation: savePulse 1s ease-in-out infinite;
}
.save-indicator[data-state="saving"] .save-dot {
background: var(--info); animation: savePulse 0.6s ease-in-out infinite;
}
.save-indicator[data-state="error"] .save-dot {
background: var(--alert); box-shadow: 0 0 6px rgba(248, 113, 113, 0.5);
}
.save-indicator[data-state="error"] { color: var(--alert); }
@keyframes savePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.add-form {
display: flex; gap: 8px; align-items: center;
background: var(--surface-2);
@@ -1014,7 +1046,12 @@
<button type="submit" class="primary">+ Hinzufügen</button>
</form>
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
<button onclick="saveLayout()" class="primary" title="Layout auf Server speichern">Speichern</button>
<!-- BUG-05: Speichern-Button entfernt (redundant zu debouncedSave).
Stattdessen subtiler Save-Status-Indikator rechts. -->
<div class="save-indicator" id="saveIndicator" data-state="idle">
<span class="save-dot"></span>
<span class="save-label">Gespeichert</span>
</div>
</div>
<div id="gridPreview" class="grid-preview"></div>
<div class="layout-hint">
@@ -1244,8 +1281,50 @@
updateClock();
// ============ DRAG & DROP LAYOUT EDITOR ============
async function saveLayout() {
// BUG-05: Status-Indikator statt redundantem "Speichern"-Button.
// Status-Lebenszyklus:
// pending → saving → idle (success) | error
setSaveState('saving');
const r = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: layoutItems }),
});
const j = await r.json();
if (!j.ok) {
setSaveState('error', 'Fehler: ' + (j.error || '?'));
toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000);
return;
}
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
setSaveState('idle', 'Gespeichert');
// nicht mehr länger toasten — der Indicator reicht
}
// BUG-05: Save-Status-Indikator Helper
function setSaveState(state, label) {
const el = document.getElementById('saveIndicator');
if (!el) return;
el.dataset.state = state;
const lbl = el.querySelector('.save-label');
if (lbl && label) lbl.textContent = label;
// Idle-Reset: nach kurzer Anzeige "Gespeichert" zurück
if (state === 'saving' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Speichern…';
}
if (state === 'idle' && !label) {
const lblEl = el.querySelector('.save-label');
if (lblEl) lblEl.textContent = 'Gespeichert';
}
}
// BUG-05: debouncedSave setzt pending-Status
let saveTimer = null;
function debouncedSave() {
setSaveState('pending', 'Ungespeichert…');
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveLayout, 500);
}
@@ -1608,19 +1687,7 @@
renderGrid();
toast('Widget hinzugefügt', 'success', 2000);
}
async function saveLayout() {
const r = await fetch('/api/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: layoutItems }),
});
const j = await r.json();
if (!j.ok) { toast('Layout speichern fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
layoutItems = j.items;
renderGrid();
setTimeout(refreshPreview, 500);
toast('Layout gespeichert', 'success');
}
// BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
async function packAll() {
const ok = await modalConfirm({
icon: 'warn',
+74
View File
@@ -0,0 +1,74 @@
// BUG-05 Test: Speichern-Button entfernt, Save-Indicator hinzugefügt.
//
// Erwartung:
// 1) KEIN <button onclick="saveLayout()"> im Layout-Toolbar mehr
// 2) NEUER #saveIndicator im Toolbar mit data-state="idle"
// 3) setSaveState() existiert und akzeptiert (state, label?)
// 4) setSaveState setzt el.dataset.state und label-Text
// 5) debouncedSave setzt Status auf "pending" BEVOR es speichert
// 6) saveLayout setzt Status auf "saving" am Anfang, "idle" am Ende (success)
// 7) saveLayout setzt Status auf "error" bei j.ok=false
// 8) CSS: .save-indicator[data-state="..."] für idle/pending/saving/error
const fs = require('fs');
const html = fs.readFileSync('templates/index.html', 'utf8');
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
function check(name, fn) {
const r = fn();
console.log((r ? '✓' : '✗') + ' ' + name);
if (!r) process.exitCode = 1;
}
// 1) Kein Speichern-Button mehr
check('Speichern-Button entfernt', () => {
// Suchen wir den toolbar-Bereich
const toolbarMatch = html.match(/<div class="layout-toolbar">([\s\S]*?)<\/div>\s*<div id="gridPreview"/);
if (!toolbarMatch) return false; // Struktur geändert?
return !/onclick="saveLayout\(\)"/.test(toolbarMatch[1]);
});
// 2) Save-Indicator
check('Save-Indicator #saveIndicator vorhanden', () =>
/id="saveIndicator"/.test(html) && /data-state="idle"/.test(html));
// 3) setSaveState existiert
check('setSaveState() Funktion definiert', () =>
/function setSaveState\(/.test(js));
// 4) setSaveState setzt state und label
check('setSaveState setzt dataset.state', () =>
/setSaveState[\s\S]{0,300}dataset\.state/.test(js));
// 5) debouncedSave setzt pending
check('debouncedSave setzt pending-Status', () =>
/function debouncedSave\(\)\s*\{[^}]*setSaveState\(['"]pending['"]/.test(js));
// 6) saveLayout setzt saving→idle
check('saveLayout setzt saving am Anfang', () =>
/async function saveLayout\(\)[\s\S]{0,300}setSaveState\(['"]saving['"]/.test(js));
check('saveLayout setzt idle bei Erfolg', () =>
/saveLayout[\s\S]{0,800}setSaveState\(['"]idle['"]/.test(js));
// 7) saveLayout setzt error bei Fehler
check('saveLayout setzt error bei j.ok=false', () =>
/!j\.ok[\s\S]{0,300}setSaveState\(['"]error['"]/.test(js));
// 8) CSS für alle States
const cssStates = ['idle', 'pending', 'saving', 'error'].map(s =>
new RegExp(`\\.save-indicator\\[data-state="${s}"\\]`));
check('CSS für alle 4 Indicator-States', () =>
cssStates.every(re => re.test(css)));
// 9) delete success-toast raus (Indicator reicht)
check('Kein success-toast in saveLayout mehr', () =>
!/function saveLayout[\s\S]{0,1000}toast\([^)]*['"]success['"][^)]*['"]Layout gespeichert['"]/.test(js));
// 10) margin-left:auto für rechtsbündige Position
check('Save-Indicator rechtsbündig (margin-left:auto)', () =>
/\.save-indicator\s*\{[^}]*margin-left:\s*auto/.test(css));
console.log('\n========');
process.exit(process.exitCode || 0);