Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6919c5104 |
@@ -280,7 +280,15 @@ def api_layout():
|
|||||||
|
|
||||||
@app.route("/api/layout/add", methods=["POST"])
|
@app.route("/api/layout/add", methods=["POST"])
|
||||||
def api_layout_add():
|
def api_layout_add():
|
||||||
"""Fügt ein neues Item hinzu und packt automatisch."""
|
"""Fügt ein neues Item hinzu OHNE bestehende Items zu verschieben (BUG-04).
|
||||||
|
|
||||||
|
BUG-04: Vorher wurde `layout_mod.pack()` aufgerufen, das alle Items neu
|
||||||
|
sortiert und in scan-line packt — ein 1x1 hello konnte einen 2x1 spotify
|
||||||
|
aus seiner Position drängen. Jetzt first-fit: das neue Item landet in der
|
||||||
|
ersten freien Zelle, alle anderen bleiben unverändert.
|
||||||
|
|
||||||
|
Falls kein Platz: HTTP 409 mit Hinweis auf den Auto-Pack-Button.
|
||||||
|
"""
|
||||||
a = require_auth()
|
a = require_auth()
|
||||||
if a: return a
|
if a: return a
|
||||||
plugin = request.form.get("plugin", "hello").strip()
|
plugin = request.form.get("plugin", "hello").strip()
|
||||||
@@ -297,14 +305,19 @@ def api_layout_add():
|
|||||||
except: pass
|
except: pass
|
||||||
import secrets
|
import secrets
|
||||||
new_id = secrets.token_hex(4)
|
new_id = secrets.token_hex(4)
|
||||||
items = cfg.setdefault("layout", {}).setdefault("items", [])
|
items_list = cfg.setdefault("layout", {}).setdefault("items", [])
|
||||||
new_item = layout_mod.Item(new_id, plugin, 0, 0, w, h).to_dict()
|
existing = [layout_mod.Item.from_dict(d) for d in items_list]
|
||||||
items.append(new_item)
|
candidate = layout_mod.Item(new_id, plugin, 0, 0, w, h)
|
||||||
# Pack alle (inkl. neue)
|
placed = layout_mod.first_fit(candidate, existing)
|
||||||
packed = layout_mod.pack([layout_mod.Item.from_dict(d) for d in items])
|
if placed is None:
|
||||||
cfg["layout"]["items"] = [it.to_dict() for it in packed]
|
return jsonify({
|
||||||
|
"ok": False,
|
||||||
|
"error": f"kein Platz für {w}×{h}-Item — bitte Auto-Pack klicken oder Items manuell verkleinern.",
|
||||||
|
"hint": "use_auto_pack",
|
||||||
|
}), 409
|
||||||
|
items_list.append(placed.to_dict())
|
||||||
dashboard_mod.save_config(cfg)
|
dashboard_mod.save_config(cfg)
|
||||||
return jsonify({"ok": True, "id": new_id, "items": cfg["layout"]["items"]})
|
return jsonify({"ok": True, "id": new_id, "items": items_list})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
|
@app.route("/api/layout/item/<iid>", methods=["DELETE", "PATCH"])
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ def pack(items: list[Item], order: Optional[list[str]] = None) -> list[Item]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def first_fit(item: Item, others: list[Item]) -> Item | None:
|
||||||
|
"""Place the item at the first free scan-line position without moving others.
|
||||||
|
|
||||||
|
Returns the item with x/y set, or None if no placement fits.
|
||||||
|
Used by /api/layout/add so a new widget does not reshuffle the grid.
|
||||||
|
"""
|
||||||
|
w = max(1, min(GRID_COLS, item.w))
|
||||||
|
h = max(1, min(GRID_ROWS, item.h))
|
||||||
|
for y in range(GRID_ROWS - h + 1):
|
||||||
|
for x in range(GRID_COLS - w + 1):
|
||||||
|
candidate = Item(item.id, item.plugin, x, y, w, h)
|
||||||
|
cells = cells_occupied(candidate)
|
||||||
|
if all(not (cells & cells_occupied(o)) for o in others):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
|
def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
|
||||||
"""Default size when user adds a new item."""
|
"""Default size when user adds a new item."""
|
||||||
presets = {
|
presets = {
|
||||||
|
|||||||
+19
-69
@@ -797,21 +797,6 @@
|
|||||||
.toast.info .toast-icon { color: var(--info); }
|
.toast.info .toast-icon { color: var(--info); }
|
||||||
.toast.warn .toast-icon { color: var(--warn); }
|
.toast.warn .toast-icon { color: var(--warn); }
|
||||||
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
|
.toast-text { flex: 1; min-width: 0; word-break: break-word; }
|
||||||
.toast-action {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: var(--surface-3);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
color: var(--fg);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */
|
|
||||||
}
|
|
||||||
.toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); }
|
|
||||||
.toast.success .toast-action { border-color: var(--success); color: var(--success); }
|
|
||||||
.toast.success .toast-action:hover { background: var(--success); color: var(--bg); }
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateX(20px); }
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
to { opacity: 1; transform: translateX(0); }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
@@ -1150,25 +1135,12 @@
|
|||||||
// ============ Toast-Helper ============
|
// ============ Toast-Helper ============
|
||||||
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
|
// Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn")
|
||||||
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
|
// Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken
|
||||||
// Optional: toast(text, type, duration_ms, action={label, onClick})
|
function toast(text, type = "info", duration = 3500) {
|
||||||
// — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick
|
|
||||||
// auf und dismissed den Toast.
|
|
||||||
function toast(text, type = "info", duration = 3500, action = null) {
|
|
||||||
const c = document.getElementById('toastContainer');
|
const c = document.getElementById('toastContainer');
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'toast ' + type;
|
el.className = 'toast ' + type;
|
||||||
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ';
|
||||||
if (action) {
|
|
||||||
el.innerHTML = `<div class="toast-icon">${iconChar}</div>
|
|
||||||
<div class="toast-text"></div>
|
|
||||||
<button class="toast-action">${action.label}</button>`;
|
|
||||||
el.querySelector('.toast-action').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try { action.onClick(); } finally { remove(); }
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
|
el.innerHTML = `<div class="toast-icon">${iconChar}</div><div class="toast-text"></div>`;
|
||||||
}
|
|
||||||
el.querySelector('.toast-text').textContent = text;
|
el.querySelector('.toast-text').textContent = text;
|
||||||
let removed = false;
|
let removed = false;
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
@@ -1325,6 +1297,7 @@
|
|||||||
// Place items into their origin cell
|
// Place items into their origin cell
|
||||||
const occ = occupiedCells();
|
const occ = occupiedCells();
|
||||||
layoutItems.forEach((it, idx) => {
|
layoutItems.forEach((it, idx) => {
|
||||||
|
const cellIdx = it.x + ',' + it.y;
|
||||||
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
|
const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`);
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -1332,7 +1305,7 @@
|
|||||||
div.className = 'grid-item';
|
div.className = 'grid-item';
|
||||||
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
|
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
|
||||||
div.dataset.idx = idx;
|
div.dataset.idx = idx;
|
||||||
div.draggable = true; // legacy HTML5-DnD bleibt für Move; Resize nutzt separaten Handler
|
div.draggable = true;
|
||||||
|
|
||||||
// OOB marker
|
// OOB marker
|
||||||
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
|
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
|
||||||
@@ -1348,19 +1321,12 @@
|
|||||||
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
|
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// BUG-03: Span-Geometrie per CSS Grid (grid-column/grid-row) statt
|
if (originCell) {
|
||||||
// per Cell-DOM-Anker. Item wird direkt in den Grid-Container gehängt,
|
originCell.classList.add('occupied');
|
||||||
// nicht in die Origin-Cell. Damit:
|
originCell.appendChild(div);
|
||||||
// - NxN-Items rendern visuell über NxN Cells
|
} else {
|
||||||
// - Drag-Events auf Nachbar-Cells werden nicht vom Item-DOM
|
|
||||||
// verschluckt (Item ist nicht mehr Kind der Cell)
|
|
||||||
// - Resize (applySize in startResizePointer) kann den Span nahtlos
|
|
||||||
// aktualisieren ohne den DOM-Anker zu wechseln
|
|
||||||
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
|
|
||||||
div.style.gridRow = `${it.y + 1} / span ${it.h}`;
|
|
||||||
cont.appendChild(div);
|
cont.appendChild(div);
|
||||||
// Origin-Cell nur als "occupied" markieren, damit sie ihre leere Optik verliert
|
}
|
||||||
if (originCell) originCell.classList.add('occupied');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark occupied cells
|
// Mark occupied cells
|
||||||
@@ -1637,7 +1603,15 @@
|
|||||||
}
|
}
|
||||||
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
const r = await fetch('/api/layout/add', { method: 'POST', body: fd });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!j.ok) { toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000); return; }
|
if (!j.ok) {
|
||||||
|
// BUG-04: 409 = kein Platz mehr, schlage Auto-Pack vor
|
||||||
|
if (r.status === 409 && j.hint === 'use_auto_pack') {
|
||||||
|
toast(j.error || 'Kein Platz', 'warn', 6000);
|
||||||
|
} else {
|
||||||
|
toast('Fehler beim Hinzufügen: ' + (j.error || '?'), 'error', 5000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
layoutItems = j.items;
|
layoutItems = j.items;
|
||||||
renderGrid();
|
renderGrid();
|
||||||
toast('Widget hinzugefügt', 'success', 2000);
|
toast('Widget hinzugefügt', 'success', 2000);
|
||||||
@@ -1659,41 +1633,17 @@
|
|||||||
const ok = await modalConfirm({
|
const ok = await modalConfirm({
|
||||||
icon: 'warn',
|
icon: 'warn',
|
||||||
title: 'Alle Items neu anordnen?',
|
title: 'Alle Items neu anordnen?',
|
||||||
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.',
|
body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.',
|
||||||
confirmText: 'Neu anordnen',
|
confirmText: 'Neu anordnen',
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
// BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo.
|
|
||||||
// server gibt neue Items zurück; wir können das alte Layout wiederherstellen.
|
|
||||||
const snapshot = JSON.stringify(layoutItems);
|
|
||||||
const r = await fetch('/api/layout/pack', { method: 'POST' });
|
const r = await fetch('/api/layout/pack', { method: 'POST' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; }
|
||||||
layoutItems = j.items;
|
layoutItems = j.items;
|
||||||
renderGrid();
|
renderGrid();
|
||||||
debouncedSave();
|
debouncedSave();
|
||||||
// BUG-06: Actionable Toast mit Undo-Button
|
toast('Layout automatisch angeordnet', 'success');
|
||||||
toast('Layout automatisch angeordnet', 'success', 10000, {
|
|
||||||
label: 'Rückgängig',
|
|
||||||
onClick: async () => {
|
|
||||||
try {
|
|
||||||
const restored = JSON.parse(snapshot);
|
|
||||||
// restore via normalen Save-Endpunkt
|
|
||||||
const r2 = await fetch('/api/layout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ items: restored }),
|
|
||||||
});
|
|
||||||
const j2 = await r2.json();
|
|
||||||
if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; }
|
|
||||||
layoutItems = j2.items;
|
|
||||||
renderGrid();
|
|
||||||
toast('Layout wiederhergestellt', 'info', 3000);
|
|
||||||
} catch (err) {
|
|
||||||
toast('Undo Fehler: ' + err.message, 'error', 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Plugin-Configs ============
|
// ============ Plugin-Configs ============
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
// BUG-06 Test: Auto-Pack Undo via Snapshot
|
|
||||||
//
|
|
||||||
// Erwartung:
|
|
||||||
// 1) packAll() speichert layoutItems als JSON vor dem Pack
|
|
||||||
// 2) Toast wird mit action={label:"Rückgängig", onClick} aufgerufen (statt nur Text)
|
|
||||||
// 3) onClick sendet POST /api/layout mit den snapshot-Items
|
|
||||||
// 4) Modal-Text erwähnt "10 Sekunden lang rückgängig"
|
|
||||||
// 5) CSS: .toast-action Klasse vorhanden
|
|
||||||
// 6) toast()-Funktion unterstützt action-Parameter (4-Args-Signatur)
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
|
||||||
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
|
|
||||||
|
|
||||||
function check(name, fn) {
|
|
||||||
const r = fn();
|
|
||||||
console.log((r ? '✓' : '✗') + ' ' + name);
|
|
||||||
if (!r) process.exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Snapshot wird erstellt (JSON.stringify vor pack-Call)
|
|
||||||
check('packAll speichert Snapshot (JSON.stringify(layoutItems))', () =>
|
|
||||||
/async function packAll[\s\S]*?JSON\.stringify\(layoutItems\)/.test(js));
|
|
||||||
|
|
||||||
// 2) Toast mit action-Parameter
|
|
||||||
check('Toast wird mit action-Parameter aufgerufen', () =>
|
|
||||||
/toast\([^,]+,\s*['"]success['"],\s*\d+,\s*\{[\s\S]*?label:\s*['"]Rückgängig['"]/.test(js));
|
|
||||||
|
|
||||||
// 3) Undo-Click ruft POST /api/layout mit snapshot
|
|
||||||
check('Undo ruft POST /api/layout mit snapshot-Items', () =>
|
|
||||||
/fetch\(['"]\/api\/layout['"][\s\S]*?JSON\.parse\(snapshot\)/.test(js));
|
|
||||||
|
|
||||||
// 4) Modal-Body erwähnt Undo-Möglichkeit
|
|
||||||
check('Modal-Body erwähnt "rückgängig"', () =>
|
|
||||||
/rückgängig machen/i.test(js));
|
|
||||||
|
|
||||||
// 5) CSS: .toast-action Klasse
|
|
||||||
check('.toast-action CSS-Klasse vorhanden', () => {
|
|
||||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
|
||||||
return /\.toast-action\s*\{/.test(css);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6) toast() hat 4-Args Signatur
|
|
||||||
check('toast() unterstützt 4-Args (action-Parameter)', () =>
|
|
||||||
/function toast\(\s*text[^)]*\)\s*\{/.test(js) && /action\s*=/.test(js));
|
|
||||||
|
|
||||||
// 7) Undo-Button ruft bei Click onClick und dismissed Toast
|
|
||||||
check('Action-Button: Click ruft onClick + dismiss', () =>
|
|
||||||
/\.toast-action['"]\)\.addEventListener\(['"]click['"][\s\S]*?action\.onClick\(\)/.test(js));
|
|
||||||
|
|
||||||
// 8) Pointer-events:auto auf Action-Button (sonst klickt Toast-Click-Handler)
|
|
||||||
check('.toast-action hat pointer-events:auto', () => {
|
|
||||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
|
||||||
return /\.toast-action\s*\{[^}]*pointer-events:\s*auto/.test(css);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n========');
|
|
||||||
process.exit(process.exitCode || 0);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Test für BUG-03: Span-Geometrie im Initial-Render.
|
|
||||||
//
|
|
||||||
// Erwartung nach Fix:
|
|
||||||
// 1) renderGrid setzt grid-column/grid-row am Item direkt (per JS).
|
|
||||||
// 2) Item wird in den Container gehängt (cont.appendChild), nicht in originCell.
|
|
||||||
// 3) Origin-Cell bekommt nur die "occupied"-Klasse.
|
|
||||||
// 4) 2x2-Item hat style.gridColumn === '<x+1> / span 2' und gridRow === '<y+1> / span 2'.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const html = fs.readFileSync('templates/index.html', 'utf8');
|
|
||||||
const js = html.match(/<script>\s*\n([\s\S]*?)<\/script>/)[1];
|
|
||||||
|
|
||||||
function check(name, fn) {
|
|
||||||
const r = fn();
|
|
||||||
console.log((r ? '✓' : '✗') + ' ' + name);
|
|
||||||
if (!r) process.exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) renderGrid setzt style.gridColumn/gridRow am Item
|
|
||||||
check('gridColumn wird per JS gesetzt', () =>
|
|
||||||
/\.style\.gridColumn\s*=/.test(js));
|
|
||||||
|
|
||||||
check('gridRow wird per JS gesetzt', () =>
|
|
||||||
/\.style\.gridRow\s*=/.test(js));
|
|
||||||
|
|
||||||
// 2) Item wird in cont.appendChild gehängt, NICHT in originCell.appendChild
|
|
||||||
check('Item wird in Container (cont) gehängt', () =>
|
|
||||||
/cont\.appendChild\(div\)/.test(js));
|
|
||||||
|
|
||||||
check('Item wird NICHT mehr in Origin-Cell gehängt', () =>
|
|
||||||
!/originCell\.appendChild\(div\)/.test(js));
|
|
||||||
|
|
||||||
// 3) Origin-Cell bekommt nur occupied-Klasse
|
|
||||||
check('Origin-Cell bekommt "occupied" Klasse', () =>
|
|
||||||
/originCell\.classList\.add\(['"]occupied['"]\)/.test(js));
|
|
||||||
|
|
||||||
// 4) Format: `${it.x + 1} / span ${it.w}` (CSS-Grid-Notation)
|
|
||||||
check('gridColumn Format: <x+1> / span <w>', () =>
|
|
||||||
/it\.x\s*\+\s*1[^`]*\$\{it\.w\}/.test(js) || /\$\{it\.x\s*\+\s*1\}[^`]*span[^`]*\$\{it\.w\}/.test(js));
|
|
||||||
|
|
||||||
// 5) Kein 100%/100% Trick auf Items (das war der Bug, der Span verhindert hat)
|
|
||||||
check('Keine "width: 100%" mehr im Item-CSS-Block', () => {
|
|
||||||
const css = html.match(/<style>([\s\S]*?)<\/style>/g).join('\n');
|
|
||||||
// .grid-item soll nicht width:100% haben
|
|
||||||
const itemCssMatch = /\.grid-item\s*\{([^}]*)\}/.exec(css);
|
|
||||||
if (!itemCssMatch) return true; // falls keine Regel
|
|
||||||
const body = itemCssMatch[1];
|
|
||||||
return !/width:\s*100%/.test(body);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6) Visuelle Begründung im CSS-Kommentar (für die Nachwelt)
|
|
||||||
check('CSS-Kommentar erwähnt BUG-03 Span-Geometrie', () =>
|
|
||||||
/BUG-03/i.test(html));
|
|
||||||
|
|
||||||
console.log('\n========');
|
|
||||||
process.exit(process.exitCode || 0);
|
|
||||||
Reference in New Issue
Block a user