From bf5b7f8e6353c56c5f056c4598c3819fc17e23eb Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 12 Sep 2026 22:35:08 +0200 Subject: [PATCH] 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 --- IDEAS.md | 33 +++++--- README.md | 33 ++++---- web/css/style.css | 36 ++++++-- web/js/os.js | 209 +++++++++++++++++++++++----------------------- 4 files changed, 177 insertions(+), 134 deletions(-) diff --git a/IDEAS.md b/IDEAS.md index 50f957b..85a8eb0 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -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 diff --git a/README.md b/README.md index 299a40e..5ade2f3 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/web/css/style.css b/web/css/style.css index 5bebf19..94eb881 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -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. */ diff --git a/web/js/os.js b/web/js/os.js index 1eac614..cd11604 100644 --- a/web/js/os.js +++ b/web/js/os.js @@ -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) => ` + + + + `; + + const kopfHtml = (z, i) => ` +
+ + + ${pfeile(i, z.name || "kategorie")} +
`; + const rowHtml = (a, i) => `
${glyphSvg(a.glyph)} ${escapeHtml(a.name || a.id || "neue app")} ${escapeHtml(a.url || "")} - - - - + ${pfeile(i, a.name || a.id)}
- -

frei wählbar — tippen legt sie an, leer lässt heisst „ohne“

`; - /* 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 - ? `${bekannt - .map((k) => ``) - .join("")}` + - gruppen - .map((g, gi) => { - const kopf = g.kategorie - ? `
- ${escapeHtml(g.kategorie)} - - - - -
` - : ""; - 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("") : `
Noch keine App eingetragen.
Mit „+ neue app" die erste anlegen.
`; 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 : 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() { `
@@ -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();