Apps lassen sich in Kategorien gruppieren

Ein Feld an der App, kein zweites Datenmodell: Eine Kategorie besteht genau
so lange, wie eine App sie traegt. Damit gibt es nichts anzulegen, nichts zu
loeschen und keine zweite Reihenfolge, die mit der bestehenden aus dem Tritt
geraten koennte.

Die Reihenfolge der Ueberschriften ist die ihres ersten Auftretens in der
Liste -- also ordnen dieselben Pfeile wie bisher beides. Am Rand einer Gruppe
haengt der Pfeil die App in die Nachbargruppe um, statt zu tauschen: derselbe
Knopf zum Sortieren und zum Umkategorisieren, und keine App kann zwischen
zwei Gruppen liegen bleiben.

Eingesammelt wird im Server, beim Schreiben wie beim Lesen. Das war nicht der
erste Entwurf: Zuerst sortierte die Oberflaeche und die HoneyBar setzte ihre
Ueberschriften beim Durchlaufen -- prompt stand eine App ohne Kategorie in der
HoneyBar unter der letzten Ueberschrift und auf dem Schreibtisch ganz oben.
Bei zwei Lesern derselben Liste gehoert die Reihenfolge dorthin, wo die Liste
liegt.

Ueberschriften erscheinen, wo man sucht: Kacheln, Schublade beider Leisten,
Programme-Fenster. Nicht im App-Streifen (32 Pixel) und nicht bei den
Schreibtischsymbolen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 22:12:03 +02:00
1 parent 3b47003492
commit 501c3b8930
8 files changed
+394 -16

No files matched your search

+122 -14
View File
@@ -1,5 +1,5 @@
import {
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch,
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, gruppiere, isTouch,
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
SS_BOOTED, state,
} from "./core.js";
@@ -285,7 +285,7 @@ function renderTiles() {
const wrap = $("tiles");
wrap.innerHTML = "";
state.cfg.apps.forEach((app) => {
const kachel = (app) => {
const a = document.createElement("a");
a.className = "tile";
a.href = app.url;
@@ -300,7 +300,25 @@ function renderTiles() {
e.preventDefault();
launch(app);
});
wrap.appendChild(a);
return a;
};
// Gruppen bekommen eine Überschrift, die namenlose erste nicht: Ohne
// Kategorien sieht die Fläche aus wie immer, nur mit ihnen entsteht Struktur.
//
// Die Überschrift bleibt ein Kind desselben Rasters und spannt über alle
// Spalten (CSS: .tiles-kopf). Ein eigenes Raster je Gruppe haette jede
// Gruppe ihre Spalten selbst bestimmen lassen — dann stehen die Kacheln
// zweier Gruppen nicht mehr untereinander.
const gruppen = gruppiere(state.cfg.apps);
gruppen.forEach((g) => {
if (g.kategorie) {
const h = document.createElement("h2");
h.className = "tiles-kopf";
h.textContent = g.kategorie;
wrap.appendChild(h);
}
g.apps.forEach((app) => wrap.appendChild(kachel(app)));
});
const add = document.createElement("button");
@@ -512,13 +530,19 @@ function openSystemWindow() {
}
function openProgramsWindow() {
const rows = state.cfg.apps
const rows = gruppiere(state.cfg.apps)
.map(
(a) => `<button data-launch="${a.id}">
(g) =>
(g.kategorie ? `<div class="al-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map(
(a) => `<button data-launch="${a.id}">
<span class="al-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name)}</span>
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
</button>`
)
.join("")
)
.join("");
@@ -766,6 +790,12 @@ function widgetListeZeichnen(ul) {
* 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.
*/
// 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 }));
@@ -796,6 +826,9 @@ 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>
@@ -808,16 +841,56 @@ 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
? draft.map(rowHtml).join("")
? `<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>`;
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
inp.addEventListener("input", () => {
draft[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));
}
});
list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => {
@@ -836,13 +909,25 @@ function openApps() {
// 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.
const tausche = (i, j) => {
if (j < 0 || j >= draft.length) return;
[draft[i], draft[j]] = [draft[j], draft[i]];
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 || "";
}
render(root);
// 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}"]`);
// 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)}"]`);
if (wieder && !wieder.disabled) wieder.focus();
};
@@ -854,6 +939,21 @@ 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(
@@ -870,7 +970,9 @@ function openApps() {
render(root);
root.querySelector("#btnAdd").addEventListener("click", () => {
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
// 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 });
render(root);
});
@@ -951,14 +1053,20 @@ function buildAppMenu() {
`<div class="sm-head">wechseln zu</div>` +
(state.cfg.apps.length
? `<div class="drawer">` +
state.cfg.apps
gruppiere(state.cfg.apps)
.map(
(a) =>
`<button class="d-app" data-app="${escapeAttr(a.id)}">
(g) =>
(g.kategorie ? `<div class="d-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
g.apps
.map(
(a) =>
`<button class="d-app" data-app="${escapeAttr(a.id)}">
<span class="d-ico">${glyphSvg(a.glyph)}</span>
<span class="d-name">${escapeHtml(a.name)}</span>
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
</button>`
)
.join("")
)
.join("") +
`</div>`