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:
+81
-14
@@ -330,6 +330,38 @@
|
|||||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||||||
margin-bottom: 16px;
|
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 {
|
.add-form {
|
||||||
display: flex; gap: 8px; align-items: center;
|
display: flex; gap: 8px; align-items: center;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
@@ -1014,7 +1046,12 @@
|
|||||||
<button type="submit" class="primary">+ Hinzufügen</button>
|
<button type="submit" class="primary">+ Hinzufügen</button>
|
||||||
</form>
|
</form>
|
||||||
<button onclick="packAll()" title="Alle Items automatisch anordnen">Auto-Pack</button>
|
<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>
|
||||||
<div id="gridPreview" class="grid-preview"></div>
|
<div id="gridPreview" class="grid-preview"></div>
|
||||||
<div class="layout-hint">
|
<div class="layout-hint">
|
||||||
@@ -1244,8 +1281,50 @@
|
|||||||
updateClock();
|
updateClock();
|
||||||
|
|
||||||
// ============ DRAG & DROP LAYOUT EDITOR ============
|
// ============ 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;
|
let saveTimer = null;
|
||||||
function debouncedSave() {
|
function debouncedSave() {
|
||||||
|
setSaveState('pending', 'Ungespeichert…');
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(saveLayout, 500);
|
saveTimer = setTimeout(saveLayout, 500);
|
||||||
}
|
}
|
||||||
@@ -1608,19 +1687,7 @@
|
|||||||
renderGrid();
|
renderGrid();
|
||||||
toast('Widget hinzugefügt', 'success', 2000);
|
toast('Widget hinzugefügt', 'success', 2000);
|
||||||
}
|
}
|
||||||
async function saveLayout() {
|
// BUG-05: alte saveLayout entfernt — siehe oben (mit setSaveState).
|
||||||
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');
|
|
||||||
}
|
|
||||||
async function packAll() {
|
async function packAll() {
|
||||||
const ok = await modalConfirm({
|
const ok = await modalConfirm({
|
||||||
icon: 'warn',
|
icon: 'warn',
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user