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 = {
|
||||||
|
|||||||
+16
-14
@@ -1297,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');
|
||||||
@@ -1304,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;
|
||||||
@@ -1320,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
|
||||||
@@ -1609,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);
|
||||||
|
|||||||
@@ -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