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) ### Kategorien für Apps — ✅ gebaut (12.09.2026)
Überschriften über den Apps, damit aus einer Liste eine Ordnung wird. Gebaut als Überschriften über den Apps, damit aus einer Liste eine Ordnung wird.
**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.
Die Reihenfolge der Gruppen ist die ihres ersten Auftretens, also ordnen **In zwei Anläufen gebaut, und der zweite kam von Marc.** Zuerst war die Kategorie
dieselben Pfeile beides. Am Rand einer Gruppe kategorisiert der Pfeil um, statt ein Feld im App-Formular, und die Pfeile bekamen eine Sonderregel: Am Rand einer
zu tauschen — sonst bräuchte es einen zweiten Knopf, und eine App könnte Gruppe kategorisieren statt tauschen. Das funktionierte, war aber die Beschreibung
zwischen zwei Gruppen liegen bleiben. 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 **Gespeichert wird trotzdem als Feld an der App.** Die Überschrift gibt es nur im
erste Entwurf: Zuerst sortierte die Oberfläche und die HoneyBar setzte ihre 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 Überschriften beim Durchlaufen — prompt stand eine App ohne Kategorie in der
HoneyBar unter der letzten Überschrift und auf dem Schreibtisch ganz oben. Bei 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. 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 Thema), und der App-Streifen der Leiste bekommt keine Überschriften — dort ist
kein Platz. 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) ### Eigene App-Logos — verschoben aus 1.3 (29.08.2026)
**Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein **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 | | Name, Untertitel | Beschriftung auf Kachel und Symbol |
| Adresse | wohin der Start springt | | Adresse | wohin der Start springt |
| Symbol | cloud · note · box · chip · folder · gear · globe · disk | | 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 **Die Reihenfolge machst du mit den Pfeilen** ▲▼ in der Kopfzeile jeder Zeile — sie gilt
überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps
@@ -138,19 +137,25 @@ DIENSTE
GITEA POCKETID GITEA POCKETID
``` ```
**Eine Liste von Kategorien gibt es nicht.** Eine Kategorie besteht genau so lange, wie **Angelegt wird eine Kategorie als Zeile in der Liste**, mit „+ kategorie". Was darunter
eine App sie trägt: Du legst sie an, indem du sie in das Feld tippst, und sie verschwindet, steht, gehört dazu, bis die nächste Überschrift kommt — wie in einem Dokument. Die
wenn die letzte App sie abgibt. Nichts anzulegen, nichts zu löschen — und vor allem keine Überschrift ist eine Zeile wie jede andere: Sie hat dieselben Pfeile ▲▼, lässt sich
zweite Reihenfolge, die mit der ersten aus dem Tritt geraten könnte. 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. Was **oberhalb der ersten** Überschrift steht, hat keine Kategorie und erscheint ohne
Deshalb ordnest du Gruppen mit denselben Pfeilen: In der Kopfzeile einer Gruppe verschieben Überschrift ganz oben. Wer keine anlegt, sieht alles wie zuvor.
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.
Apps **ohne** Kategorie bilden die erste Gruppe, und die trägt keine Überschrift: Wer keine **Eine Kategorie besteht nur durch ihre Apps.** Gespeichert wird sie als Feld an jeder App,
Kategorien vergibt, sieht alles wie zuvor. 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, Ü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 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 HoneyBar ihre Überschriften beim Durchlaufen setzte und eine App ohne Kategorie am Ende
unter der letzten Überschrift landete. 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 ## Die Schublade
Der Pfeil rechts in beiden Leisten klappt alle Apps als Raster auf: **Symbol, 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; } .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 { .ae-gruppe {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 6px;
gap: 8px;
padding: 10px 4px 3px; 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; font-size: 10px;
letter-spacing: .18em; letter-spacing: .18em;
text-transform: uppercase; 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 /* Die Schublade ist ein Raster mit drei Spalten — die Überschrift spannt
darüber, sonst stünde sie als vierte Kachel neben den Apps. */ 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 * 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 * 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. * 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() { 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, // 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 // 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 WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g)); 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) => ` const rowHtml = (a, i) => `
<details class="approw" ${a.__offen ? "open" : ""}> <details class="approw" ${a.__offen ? "open" : ""}>
<summary> <summary>
<span class="ae-glyph">${glyphSvg(a.glyph)}</span> <span class="ae-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name || a.id || "neue app")}</span> <span>${escapeHtml(a.name || a.id || "neue app")}</span>
<span class="ae-url">${escapeHtml(a.url || "")}</span> <span class="ae-url">${escapeHtml(a.url || "")}</span>
<span class="ae-move"> ${pfeile(i, a.name || a.id)}
<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>
</summary> </summary>
<div class="ae-body"> <div class="ae-body">
<label class="ae-field"><span>ID</span> <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> <input data-i="${i}" data-f="name" value="${escapeAttr(a.name || "")}" placeholder="AETHER" /></label>
<label class="ae-field"><span>Untertitel</span> <label class="ae-field"><span>Untertitel</span>
<input data-i="${i}" data-f="subtitle" value="${escapeAttr(a.subtitle || "")}" placeholder="Cloud &amp; Dateien" /></label> <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> <label class="ae-field"><span>Adresse</span>
<input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label> <input data-i="${i}" data-f="url" value="${escapeAttr(a.url || "")}" placeholder="https://…" spellcheck="false" /></label>
<label class="ae-field"><span>Symbol</span> <label class="ae-field"><span>Symbol</span>
@@ -841,93 +885,61 @@ function openApps() {
</div> </div>
</details>`; </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 render = (root) => {
const list = root.querySelector("#appList"); const list = root.querySelector("#appList");
einsammeln(); list.innerHTML = zeilen.length
const gruppen = gruppiere(draft); ? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).join("")
// 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("")
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`; : `<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) => { list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => { 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) => list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => { 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); render(root);
}) })
); );
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne // Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
// das klappte dabei alles zu, was man gerade bearbeitet. // 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", () => { 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 // Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
// Sortierklick die Zeile auf oder zu. // Sortierklick die Zeile auf oder zu. Getauscht wird stumpf Nachbar gegen
// // Nachbar — dass eine App dabei die Gruppe wechselt oder eine ganze Gruppe
// An einer Gruppengrenze wird nicht getauscht, sondern umkategorisiert: // wandert, ergibt sich von selbst und braucht keine eigene Regel.
// 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.
const tausche = (i, j) => { const tausche = (i, j) => {
if (j < 0 || j >= draft.length) return; if (j < 0 || j >= zeilen.length) return;
const a = draft[i]; [zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
const nachbar = draft[j];
if ((a.kategorie || "") === (nachbar.kategorie || "")) {
[draft[i], draft[j]] = [draft[j], draft[i]];
} else {
a.kategorie = nachbar.kategorie || "";
}
render(root); render(root);
// Zurueck auf denselben Knopf, damit man mehrmals hintereinander druecken // Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
// kann, ohne die Maus nachzufuehren. // kann, ohne die Maus nachzuführen.
const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${draft.indexOf(a)}"]`); const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${j}"]`);
if (wieder && !wieder.disabled) wieder.focus(); if (wieder && !wieder.disabled) wieder.focus();
}; };
@@ -939,21 +951,6 @@ function openApps() {
tausche(i, "up" in b.dataset ? i - 1 : i + 1); 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( openPanel(
@@ -962,6 +959,7 @@ function openApps() {
`<div class="apped"><div id="appList"></div> `<div class="apped"><div id="appList"></div>
<div class="modal-actions"> <div class="modal-actions">
<span class="saveline" id="appMsg"></span> <span class="saveline" id="appMsg"></span>
<button class="btn" id="btnKat">+ kategorie</button>
<button class="btn" id="btnAdd">+ neue app</button> <button class="btn" id="btnAdd">+ neue app</button>
<button class="btn btn-primary" id="btnSaveApps">speichern</button> <button class="btn btn-primary" id="btnSaveApps">speichern</button>
</div> </div>
@@ -970,22 +968,27 @@ function openApps() {
render(root); render(root);
root.querySelector("#btnAdd").addEventListener("click", () => { root.querySelector("#btnAdd").addEventListener("click", () => {
// Ans Ende der eigenen Gruppe — eine neue App hat keine Kategorie, // Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
// steht also unten in der namenlosen ersten. Sortiert wird mit den zeilen.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
// Pfeilen, nicht beim Anlegen.
draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
render(root); 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 () => { root.querySelector("#btnSaveApps").addEventListener("click", async () => {
const msg = root.querySelector("#appMsg"); const msg = root.querySelector("#appMsg");
msg.dataset.kind = ""; msg.dataset.kind = "";
msg.textContent = "speichere …"; msg.textContent = "speichere …";
try { try {
const clean = draft.map(({ __offen, ...rest }) => rest); state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps; const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id)); zeilen = zeilenAus(state.cfg.apps).map((z) =>
draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.id) })); z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
);
msg.textContent = `${state.cfg.apps.length} gespeichert.`; msg.textContent = `${state.cfg.apps.length} gespeichert.`;
render(root); render(root);
renderMenubarApps(); renderMenubarApps();