Kategorien sind Zeilen in der Liste, kein Feld am Formular

Marcs Vorschlag, und er ist der bessere: "+ kategorie" setzt eine
Ueberschriftenzeile, und was darunter steht, gehoert dazu, bis die naechste
kommt. Die Ueberschrift hat dieselben Pfeile wie eine App, laesst sich
umbenennen und mit x aufloesen.

Damit faellt die Sonderregel von gestern weg -- "am Rand der Gruppe
kategorisiert der Pfeil um, statt zu tauschen". Sie war die Beschreibung
eines Modells; jetzt ist es das Modell: Schiebt man eine App ueber eine
Ueberschrift, ist sie in der anderen Gruppe, und schiebt man eine
Ueberschrift, wandert die Grenze. Nichts davon braucht Code.

Vor allem loest es den echten Mangel der ersten Fassung: Eine Kategorie
umbenennen war ein Feld je App, jetzt ist es eine Zeile.

Gespeichert wird weiter als Feld an der App. Waere die Ueberschrift ein
Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und ohne Adresse,
und jeder Leser muesste das wissen -- Kacheln, Schublade, Pruefung auf
Erreichbarkeit und vor allem die HoneyBar in fremden Apps. Der Preis: Eine
Ueberschrift ohne Apps darunter ueberlebt das Speichern nicht. Eine
Kategorie besteht durch ihre Apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 22:35:08 +02:00
1 parent 501c3b8930
commit bf5b7f8e63
4 files changed
+177 -134

No files matched your search

+23 -10
View File
@@ -392,18 +392,27 @@ unter 64 px unbrauchbar, taugt aber für ein großes Startbild.
### Kategorien für Apps — ✅ gebaut (12.09.2026)
Überschriften über den Apps, damit aus einer Liste eine Ordnung wird. Gebaut als
**ein Feld an der App**, nicht als zweites Datenmodell: Eine Kategorie besteht,
solange eine App sie trägt — kein Anlegen, kein Löschen, keine zweite
Reihenfolge neben der bestehenden.
Überschriften über den Apps, damit aus einer Liste eine Ordnung wird.
Die Reihenfolge der Gruppen ist die ihres ersten Auftretens, also ordnen
dieselben Pfeile beides. Am Rand einer Gruppe kategorisiert der Pfeil um, statt
zu tauschen — sonst bräuchte es einen zweiten Knopf, und eine App könnte
zwischen zwei Gruppen liegen bleiben.
**In zwei Anläufen gebaut, und der zweite kam von Marc.** Zuerst war die Kategorie
ein Feld im App-Formular, und die Pfeile bekamen eine Sonderregel: Am Rand einer
Gruppe kategorisieren statt tauschen. Das funktionierte, war aber die Beschreibung
eines Modells und nicht das Modell selbst. Seine Frage — warum nicht einfach eine
Überschriftenzeile in der Liste, und was darunter steht, gehört dazu? — ist die
bessere Bedienung: Man sieht, was man sortiert, das Umhängen ergibt sich von
selbst, und **Umbenennen ist eine Zeile statt eines Feldes je App**. Genau das war
der Mangel der ersten Fassung.
Eingesammelt wird **im Server**, beim Schreiben wie beim Lesen. Das war nicht der
erste Entwurf: Zuerst sortierte die Oberfläche und die HoneyBar setzte ihre
**Gespeichert wird trotzdem als Feld an der App.** Die Überschrift gibt es nur im
Editor. Wäre sie ein Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und
ohne Adresse, und jeder Leser müsste das wissen — Kacheln, Schublade,
Erreichbarkeitsprüfung, und vor allem die HoneyBar in fremden Apps, die gerade
erst auf das Nötigste getrimmt wurde. Der Preis: Eine Überschrift ohne Apps
darunter überlebt das Speichern nicht. Das ist vertretbar — eine Kategorie besteht
durch ihre Apps.
Eingesammelt wird **im Server**, beim Schreiben wie beim Lesen. Auch das war nicht
der erste Entwurf: Zuerst sortierte die Oberfläche und die HoneyBar setzte ihre
Überschriften beim Durchlaufen — prompt stand eine App ohne Kategorie in der
HoneyBar unter der letzten Überschrift und auf dem Schreibtisch ganz oben. Bei
zwei Lesern derselben Liste gehört die Reihenfolge dorthin, wo die Liste liegt.
@@ -412,6 +421,10 @@ Offen gelassen: Schreibtischsymbole bleiben flach (Ordner wären ein eigenes
Thema), und der App-Streifen der Leiste bekommt keine Überschriften — dort ist
kein Platz.
**Falls eine leere Kategorie doch bleiben soll**, ist das das Argument für eine
echte Kategorienliste im Server — dann aber als eigenes Feld neben `apps`, nicht
als Eintrag in ihr.
### Eigene App-Logos — verschoben aus 1.3 (29.08.2026)
**Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein
+17 -16
View File
@@ -121,7 +121,6 @@ fragt über die Domaingrenze und bleibt dort leer.
| Name, Untertitel | Beschriftung auf Kachel und Symbol |
| Adresse | wohin der Start springt |
| Symbol | cloud · note · box · chip · folder · gear · globe · disk |
| Kategorie | frei wählbar, für die Gruppierung — leer heißt „ohne" |
**Die Reihenfolge machst du mit den Pfeilen** ▲▼ in der Kopfzeile jeder Zeile — sie gilt
überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps
@@ -138,19 +137,25 @@ DIENSTE
GITEA POCKETID
```
**Eine Liste von Kategorien gibt es nicht.** Eine Kategorie besteht genau so lange, wie
eine App sie trägt: Du legst sie an, indem du sie in das Feld tippst, und sie verschwindet,
wenn die letzte App sie abgibt. Nichts anzulegen, nichts zu löschen — und vor allem keine
zweite Reihenfolge, die mit der ersten aus dem Tritt geraten könnte.
**Angelegt wird eine Kategorie als Zeile in der Liste**, mit „+ kategorie". Was darunter
steht, gehört dazu, bis die nächste Überschrift kommt — wie in einem Dokument. Die
Überschrift ist eine Zeile wie jede andere: Sie hat dieselben Pfeile ▲▼, lässt sich
umbenennen und mit × auflösen. **Ein Sonderfall zum Umhängen existiert nicht:** Schiebst du
eine App über eine Überschrift, ist sie in der anderen Gruppe, und schiebst du eine
Überschrift, wandert die Grenze.
Die Reihenfolge der Überschriften ist die ihres **ersten Auftretens** in der App-Liste.
Deshalb ordnest du Gruppen mit denselben Pfeilen: In der Kopfzeile einer Gruppe verschieben
sie den ganzen Block, in der Zeile einer App die App. **Steht die App am Rand ihrer Gruppe,
hängt der Pfeil sie in die Nachbargruppe um** — derselbe Knopf, mit dem du sortierst, ist
auch der zum Umkategorisieren.
Was **oberhalb der ersten** Überschrift steht, hat keine Kategorie und erscheint ohne
Überschrift ganz oben. Wer keine anlegt, sieht alles wie zuvor.
Apps **ohne** Kategorie bilden die erste Gruppe, und die trägt keine Überschrift: Wer keine
Kategorien vergibt, sieht alles wie zuvor.
**Eine Kategorie besteht nur durch ihre Apps.** Gespeichert wird sie als Feld an jeder App,
nicht als eigener Eintrag — sonst enthielte die App-Liste Zeilen ohne ID und ohne Adresse,
und jeder Leser müsste das wissen: Kacheln, Schublade, Erreichbarkeitsprüfung und vor allem
die HoneyBar in fremden Apps. Zwei Folgen davon, die man kennen sollte:
- Eine Überschrift **ohne Apps darunter überlebt das Speichern nicht.** Beim Bearbeiten
bleibt sie stehen, danach ist sie weg.
- Eine Kategorie **umbenennen ist trotzdem eine Zeile**, nicht ein Feld je App — der Editor
schreibt das Feld beim Speichern an allen Apps der Gruppe.
Überschriften erscheinen dort, wo man **sucht** — Kacheln, Schublade beider Leisten,
Programme-Fenster. Nicht im App-Streifen der Leiste (32 Pixel, kein Platz) und nicht bei
@@ -162,10 +167,6 @@ sich, sähe dieselbe Liste an zwei Stellen verschieden aus — genau das passier
HoneyBar ihre Überschriften beim Durchlaufen setzte und eine App ohne Kategorie am Ende
unter der letzten Überschrift landete.
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
zurück, nicht als kaputter Eintrag.
## Die Schublade
Der Pfeil rechts in beiden Leisten klappt alle Apps als Raster auf: **Symbol,
+31 -5
View File
@@ -1426,19 +1426,45 @@ body {
}
.applist .al-kopf:first-child { padding-top: 2px; }
/* Im Editor trägt die Überschrift die Pfeile für die ganze Gruppe. */
/* Im Editor ist die Überschrift eine Zeile wie jede andere: Sie lässt sich
umbenennen, verschieben und auflösen. Deshalb sitzt hier ein Eingabefeld und
kein Text — Umbenennen ist eine Zeile statt eines Feldes je App. */
.ae-gruppe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 6px;
padding: 10px 4px 3px;
}
.ae-gruppe:first-child { padding-top: 2px; }
.ae-kat {
flex: 1;
min-width: 0;
background: none;
border: 1px solid transparent;
border-bottom-color: var(--p-faint);
color: var(--p-mid);
font: inherit;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
padding: 3px 4px;
}
.ae-gruppe:first-of-type { padding-top: 2px; }
.ae-kat::placeholder { color: var(--p-dim); text-transform: none; letter-spacing: normal; }
.ae-kat:focus { outline: none; border-color: var(--p-dim); background: var(--p-faint); color: var(--p); }
.ae-kat-weg {
flex: none;
background: none;
border: 1px solid transparent;
color: var(--p-dim);
font: inherit;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
}
.ae-kat-weg:hover,
.ae-kat-weg:focus-visible { color: var(--p); border-color: var(--p-dim); }
/* Die Schublade ist ein Raster mit drei Spalten — die Überschrift spannt
darüber, sonst stünde sie als vierte Kachel neben den Apps. */
+106 -103
View File
@@ -789,15 +789,50 @@ function widgetListeZeichnen(ul) {
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
*//* Bearbeitet wird eine Liste aus zweierlei Zeilen: Apps und Überschriften.
*
* Das ist bewusst **nicht** das, was gespeichert wird — gespeichert ist eine
* Kategorie als Feld an jeder App. Die Überschrift gibt es nur hier, weil sie
* hier das ehrlichere Bild ist: Man sieht, was man sortiert, und das Umhängen
* einer App braucht keine Sonderregel, es ergibt sich daraus, dass sie unter
* einer anderen Überschrift landet.
*
* In der App-Liste selbst hätten Überschriften nichts zu suchen: Dann enthielte
* sie Einträge ohne ID und ohne Adresse, und jeder Leser müsste das wissen —
* die Kachelfläche, die Schublade, die Erreichbarkeitsprüfung und vor allem
* die HoneyBar in fremden Apps, die mit so wenig wie möglich auskommen soll.
*
* Was oberhalb der ersten Überschrift steht, hat keine Kategorie.
*/
// Die namenlose Gruppe steht immer oben (siehe gruppiere in core.js) und laesst
// sich deshalb nicht verschieben — erste() sagt, ab welcher Gruppe es losgeht.
function erste(gruppen) {
return gruppen.length && gruppen[0].kategorie === "" ? 1 : 0;
}
function openApps() {
let draft = state.cfg.apps.map((a) => ({ ...a }));
const KOPF = "kopf";
// Aus der gespeicherten Liste die Zeilen bauen: je Gruppe eine Überschrift,
// darunter ihre Apps. Die Gruppe ohne Kategorie steht oben und bekommt keine.
const zeilenAus = (apps) =>
gruppiere(apps).flatMap((g) => [
...(g.kategorie ? [{ typ: KOPF, name: g.kategorie }] : []),
...g.apps.map((a) => ({ ...a })),
]);
// Und zurück: durchlaufen, die zuletzt gesehene Überschrift mitschreiben.
// Eine Überschrift ohne Apps darunter verschwindet dabei — eine Kategorie
// besteht nur durch die Apps, die sie tragen.
const appsAus = (zeilen) => {
let kategorie = "";
const out = [];
for (const z of zeilen) {
if (z.typ === KOPF) {
kategorie = z.name.trim();
continue;
}
const { __offen, ...rest } = z;
out.push({ ...rest, kategorie });
}
return out;
};
let zeilen = zeilenAus(state.cfg.apps);
// Die Auswahl kommt aus glyphs.json selbst — wer dort ein Symbol ergänzt,
// kann es sofort wählen. Die Wettersymbole liegen in derselben Datei (die
@@ -805,18 +840,30 @@ function openApps() {
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
const pfeile = (i, was) => `
<span class="ae-move">
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(was)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === zeilen.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(was)} nach unten">&#9660;</button>
</span>`;
const kopfHtml = (z, i) => `
<div class="ae-gruppe">
<input class="ae-kat" data-k="${i}" value="${escapeAttr(z.name)}"
placeholder="kategorie" aria-label="Name der Kategorie" />
<button class="ae-kat-weg" data-kdel="${i}" title="Kategorie auflösen"
aria-label="Kategorie ${escapeAttr(z.name)} auflösen">&times;</button>
${pfeile(i, z.name || "kategorie")}
</div>`;
const rowHtml = (a, i) => `
<details class="approw" ${a.__offen ? "open" : ""}>
<summary>
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
<span class="ae-url">${escapeHtml(a.url || "")}</span>
<span class="ae-move">
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
title="nach oben" aria-label="${escapeAttr(a.name || a.id)} nach oben">&#9650;</button>
<button class="ae-arrow" data-down="${i}" ${i === draft.length - 1 ? "disabled" : ""}
title="nach unten" aria-label="${escapeAttr(a.name || a.id)} nach unten">&#9660;</button>
</span>
${pfeile(i, a.name || a.id)}
</summary>
<div class="ae-body">
<label class="ae-field"><span>ID</span>
@@ -826,9 +873,6 @@ function openApps() {
<input data-i="${i}" data-f="name" value="${escapeAttr(a.name || "")}" placeholder="AETHER" /></label>
<label class="ae-field"><span>Untertitel</span>
<input data-i="${i}" data-f="subtitle" value="${escapeAttr(a.subtitle || "")}" placeholder="Cloud &amp; Dateien" /></label>
<label class="ae-field"><span>Kategorie</span>
<input data-i="${i}" data-f="kategorie" list="ae-kategorien" value="${escapeAttr(a.kategorie || "")}" placeholder="ohne" /></label>
<p class="ae-hint">frei wählbar — tippen legt sie an, leer lässt heisst „ohne“</p>
<label class="ae-field"><span>Adresse</span>
<input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label>
<label class="ae-field"><span>Symbol</span>
@@ -841,93 +885,61 @@ function openApps() {
</div>
</details>`;
/* Der Entwurf wird bei jedem Zeichnen nach Kategorien eingesammelt, bevor
* er angezeigt wird. Damit ist die Reihenfolge im Editor immer dieselbe wie
* auf der Kachelfläche — sonst könnten zwei Apps derselben Kategorie hier
* auseinanderstehen und dort zusammen, und die Pfeile bewegten etwas
* anderes, als man sieht. */
const einsammeln = () => {
draft = gruppiere(draft).flatMap((g) => g.apps);
};
const render = (root) => {
const list = root.querySelector("#appList");
einsammeln();
const gruppen = gruppiere(draft);
// Die Liste der schon vergebenen Kategorien als Vorschlag — so tippt man
// „Dienste" nicht beim zweiten Mal versehentlich „dienste".
const bekannt = [...new Set(draft.map((a) => (a.kategorie || "").trim()).filter(Boolean))];
list.innerHTML = draft.length
? `<datalist id="ae-kategorien">${bekannt
.map((k) => `<option value="${escapeAttr(k)}"></option>`)
.join("")}</datalist>` +
gruppen
.map((g, gi) => {
const kopf = g.kategorie
? `<div class="ae-gruppe">
<span>${escapeHtml(g.kategorie)}</span>
<span class="ae-move">
<button class="ae-arrow" data-gup="${gi}" ${gi <= erste(gruppen) ? "disabled" : ""}
title="Gruppe nach oben" aria-label="${escapeAttr(g.kategorie)} nach oben">&#9650;</button>
<button class="ae-arrow" data-gdown="${gi}" ${gi === gruppen.length - 1 ? "disabled" : ""}
title="Gruppe nach unten" aria-label="${escapeAttr(g.kategorie)} nach unten">&#9660;</button>
</span>
</div>`
: "";
return kopf + g.apps.map((a) => rowHtml(a, draft.indexOf(a))).join("");
})
.join("")
list.innerHTML = zeilen.length
? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).join("")
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => {
draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
zeilen[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
});
// Neu einsortiert wird erst beim Verlassen des Feldes. Bei jedem
// Tastendruck neu zu zeichnen haette den Cursor aus dem Feld geworfen,
// sobald der erste Buchstabe eine neue Gruppe aufmacht.
if (inp.dataset.f === "kategorie") {
inp.addEventListener("change", () => render(root));
}
});
// Umbenennen ist hier eine Zeile — das ist der Grund für diese Bauart.
// Neu gezeichnet wird dabei nicht, sonst verlöre das Feld den Cursor.
list.querySelectorAll("input[data-k]").forEach((inp) => {
inp.addEventListener("input", () => {
zeilen[Number(inp.dataset.k)].name = inp.value;
});
});
list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => {
draft.splice(Number(b.dataset.del), 1);
zeilen.splice(Number(b.dataset.del), 1);
render(root);
})
);
// Eine Überschrift löschen heißt: Ihre Apps rutschen in die Gruppe darüber.
// Die Apps selbst anzufassen wäre falsch — aufgelöst wird eine Kategorie,
// nicht das, was in ihr steht.
list.querySelectorAll("[data-kdel]").forEach((b) =>
b.addEventListener("click", () => {
zeilen.splice(Number(b.dataset.kdel), 1);
render(root);
})
);
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
// das klappte dabei alles zu, was man gerade bearbeitet.
list.querySelectorAll(".approw").forEach((d, i) => {
list.querySelectorAll(".approw").forEach((d) => {
const i = Number(d.querySelector("[data-up]").dataset.up);
d.addEventListener("toggle", () => {
if (draft[i]) draft[i].__offen = d.open;
if (zeilen[i]) zeilen[i].__offen = d.open;
});
});
// Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
// Sortierklick die Zeile auf oder zu.
//
// An einer Gruppengrenze wird nicht getauscht, sondern umkategorisiert:
// Die App bekommt die Kategorie der Nachbargruppe und landet beim
// Einsammeln genau an deren Rand. Damit ist derselbe Pfeil, mit dem man
// sortiert, auch das Werkzeug zum Umhaengen — ein Knopf weniger, und die
// App kann nie zwischen zwei Gruppen liegen bleiben.
// Sortierklick die Zeile auf oder zu. Getauscht wird stumpf Nachbar gegen
// Nachbar — dass eine App dabei die Gruppe wechselt oder eine ganze Gruppe
// wandert, ergibt sich von selbst und braucht keine eigene Regel.
const tausche = (i, j) => {
if (j < 0 || j >= draft.length) return;
const a = draft[i];
const nachbar = draft[j];
if ((a.kategorie || "") === (nachbar.kategorie || "")) {
[draft[i], draft[j]] = [draft[j], draft[i]];
} else {
a.kategorie = nachbar.kategorie || "";
}
if (j < 0 || j >= zeilen.length) return;
[zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
render(root);
// Zurueck auf denselben Knopf, damit man mehrmals hintereinander druecken
// kann, ohne die Maus nachzufuehren.
const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${draft.indexOf(a)}"]`);
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
// kann, ohne die Maus nachzuführen.
const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${j}"]`);
if (wieder && !wieder.disabled) wieder.focus();
};
@@ -939,21 +951,6 @@ function openApps() {
tausche(i, "up" in b.dataset ? i - 1 : i + 1);
})
);
// Ganze Gruppe verschieben: Die Reihenfolge der Ueberschriften ist die
// ihres ersten Auftretens, also genuegt es, die Bloecke zu tauschen.
list.querySelectorAll("[data-gup], [data-gdown]").forEach((b) =>
b.addEventListener("click", (e) => {
e.preventDefault();
const gruppen = gruppiere(draft);
const gi = Number(b.dataset.gup ?? b.dataset.gdown);
const gj = "gup" in b.dataset ? gi - 1 : gi + 1;
if (gj < erste(gruppen) || gj >= gruppen.length) return;
[gruppen[gi], gruppen[gj]] = [gruppen[gj], gruppen[gi]];
draft = gruppen.flatMap((g) => g.apps);
render(root);
})
);
};
openPanel(
@@ -962,6 +959,7 @@ function openApps() {
`<div class="apped"><div id="appList"></div>
<div class="modal-actions">
<span class="saveline" id="appMsg"></span>
<button class="btn" id="btnKat">+ kategorie</button>
<button class="btn" id="btnAdd">+ neue app</button>
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
</div>
@@ -970,22 +968,27 @@ function openApps() {
render(root);
root.querySelector("#btnAdd").addEventListener("click", () => {
// Ans Ende der eigenen Gruppe — eine neue App hat keine Kategorie,
// steht also unten in der namenlosen ersten. Sortiert wird mit den
// Pfeilen, nicht beim Anlegen.
draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
zeilen.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
render(root);
});
root.querySelector("#btnKat").addEventListener("click", () => {
zeilen.push({ typ: KOPF, name: "" });
render(root);
[...root.querySelectorAll(".ae-kat")].pop()?.focus();
});
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
const msg = root.querySelector("#appMsg");
msg.dataset.kind = "";
msg.textContent = "speichere …";
try {
const clean = draft.map(({ __offen, ...rest }) => rest);
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps;
const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id));
draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.id) }));
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
zeilen = zeilenAus(state.cfg.apps).map((z) =>
z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
);
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
render(root);
renderMenubarApps();