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

+22
View File
@@ -390,6 +390,28 @@ fremden CSPs, gelb statt mitgefärbt, und die Zeichenfassung fürs System-Fenste
Eine 3/4-Ansicht (gerechneter Würfel, 2:1) liegt in der Schublade — sie ist Eine 3/4-Ansicht (gerechneter Würfel, 2:1) liegt in der Schublade — sie ist
unter 64 px unbrauchbar, taugt aber für ein großes Startbild. 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.
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.
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
Ü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.
Offen gelassen: Schreibtischsymbole bleiben flach (Ordner wären ein eigenes
Thema), und der App-Streifen der Leiste bekommt keine Überschriften — dort ist
kein Platz.
### 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
+36
View File
@@ -121,11 +121,47 @@ 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
hängen sich hinten an. hängen sich hinten an.
## Kategorien
Apps lassen sich gruppieren — *Apps*, *Dienste*, *Werkzeuge*, was du willst:
```
APPS
AETHER INKNOTE BENTOPDF
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.
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.
Apps **ohne** Kategorie bilden die erste Gruppe, und die trägt keine Überschrift: Wer keine
Kategorien vergibt, sieht alles wie zuvor.
Ü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
den Schreibtischsymbolen (ein Schreibtisch ist flach; Ordner wären ein eigenes Thema).
**Die Reihenfolge legt der Server fest**, nicht die Oberfläche: `SetApps` sammelt Apps
derselben Kategorie zusammen, beim Schreiben wie beim Lesen. Sortierte jeder Leser für
sich, sähe dieselbe Liste an zwei Stellen verschieden aus — genau das passierte, als die
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 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 dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
zurück, nicht als kaputter Eintrag. zurück, nicht als kaputter Eintrag.
+48 -2
View File
@@ -33,6 +33,13 @@ type App struct {
Subtitle string `json:"subtitle,omitempty"` Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"` URL string `json:"url"`
Glyph string `json:"glyph,omitempty"` Glyph string `json:"glyph,omitempty"`
// Die Kategorie ist freier Text, kein Verweis auf eine Liste von
// Kategorien — es gibt keine. Eine Kategorie besteht genau so lange, wie
// eine App sie trägt; damit gibt es nichts anzulegen, nichts zu löschen
// und vor allem keine zweite Reihenfolge, die mit dieser aus dem Tritt
// geraten könnte. Die Reihenfolge der Überschriften ergibt sich aus dem
// ersten Auftreten in dieser Liste.
Kategorie string `json:"kategorie,omitempty"`
} }
type User struct { type User struct {
@@ -94,6 +101,10 @@ func Open(path string) (*Store, error) {
if s.d.Apps == nil { if s.d.Apps == nil {
s.d.Apps = []App{} s.d.Apps = []App{}
} }
// Auch beim Lesen einsammeln: honeyos.json darf von Hand bearbeitet werden
// (das ist der halbe Grund für eine Datei statt einer Datenbank), und dann
// stehen Kategorien schnell durcheinander.
s.d.Apps = sortiereNachKategorie(s.d.Apps)
if s.d.Users == nil { if s.d.Users == nil {
s.d.Users = map[string]*User{} s.d.Users = map[string]*User{}
} }
@@ -137,6 +148,7 @@ func (s *Store) SetApps(apps []App) error {
a.Subtitle = strings.TrimSpace(a.Subtitle) a.Subtitle = strings.TrimSpace(a.Subtitle)
a.URL = strings.TrimSpace(a.URL) a.URL = strings.TrimSpace(a.URL)
a.Glyph = strings.TrimSpace(a.Glyph) a.Glyph = strings.TrimSpace(a.Glyph)
a.Kategorie = strings.TrimSpace(a.Kategorie)
if !idPattern.MatchString(a.ID) { if !idPattern.MatchString(a.ID) {
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1) return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
@@ -156,7 +168,7 @@ func (s *Store) SetApps(apps []App) error {
if a.Glyph != "" && !knownGlyphs[a.Glyph] { if a.Glyph != "" && !knownGlyphs[a.Glyph] {
a.Glyph = "" a.Glyph = ""
} }
for _, f := range []string{a.Name, a.Subtitle, a.URL} { for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Kategorie} {
if len(f) > maxFieldLen { if len(f) > maxFieldLen {
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen) return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
} }
@@ -166,10 +178,44 @@ func (s *Store) SetApps(apps []App) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.d.Apps = cleaned s.d.Apps = sortiereNachKategorie(cleaned)
return s.save() return s.save()
} }
// sortiereNachKategorie sammelt Apps derselben Kategorie zusammen, ohne die
// Reihenfolge innerhalb einer Gruppe oder die der Gruppen zu ändern: Die
// Gruppen stehen in der Reihenfolge ihres ersten Auftretens, die namenlose
// immer zuerst.
//
// Das gehört hierher und nicht in die Oberfläche, weil es mehrere Leser gibt:
// der Schreibtisch, die HoneyBar in fremden Apps, irgendwann ein weiterer.
// Sortierte jeder für sich, sähe dieselbe Liste an zwei Stellen verschieden
// aus — genau das ist passiert, als die HoneyBar die Überschriften einfach
// beim Durchlaufen setzte und eine App ohne Kategorie am Ende unter der
// letzten Überschrift landete.
func sortiereNachKategorie(apps []App) []App {
ohne := make([]App, 0, len(apps))
reihenfolge := []string{}
nach := map[string][]App{}
for _, a := range apps {
if a.Kategorie == "" {
ohne = append(ohne, a)
continue
}
if _, da := nach[a.Kategorie]; !da {
reihenfolge = append(reihenfolge, a.Kategorie)
}
nach[a.Kategorie] = append(nach[a.Kategorie], a)
}
out := ohne
for _, k := range reihenfolge {
out = append(out, nach[k]...)
}
return out
}
func (s *Store) User(sub string) User { func (s *Store) User(sub string) User {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"path/filepath"
"testing"
)
// Die Reihenfolge der Apps ist die Anzeige-Reihenfolge — für den Schreibtisch
// wie für die HoneyBar in fremden Apps. Deshalb legt der Server sie fest und
// nicht jeder Leser für sich: Als die HoneyBar ihre Überschriften noch selbst
// beim Durchlaufen setzte, landete eine App ohne Kategorie am Ende unter der
// letzten Überschrift, während der Schreibtisch sie oben zeigte.
func TestKategorienWerdenEingesammelt(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "honeyos.json"))
if err != nil {
t.Fatal(err)
}
// Absichtlich durcheinander: zwei Gruppen verschränkt, die App ohne
// Kategorie zuletzt.
err = s.SetApps([]App{
{ID: "a", URL: "https://e.test/a", Kategorie: "Apps"},
{ID: "g", URL: "https://e.test/g", Kategorie: "Dienste"},
{ID: "i", URL: "https://e.test/i", Kategorie: "Apps"},
{ID: "p", URL: "https://e.test/p", Kategorie: "Dienste"},
{ID: "l", URL: "https://e.test/l"},
})
if err != nil {
t.Fatal(err)
}
will := []string{"l", "a", "i", "g", "p"}
hat := []string{}
for _, a := range s.Apps() {
hat = append(hat, a.ID)
}
for i := range will {
if i >= len(hat) || hat[i] != will[i] {
t.Fatalf("Reihenfolge %v, erwartet %v — ohne Kategorie zuerst, dann die Gruppen in der Reihenfolge ihres ersten Auftretens", hat, will)
}
}
// Und nach dem Neuladen dasselbe: Wer honeyos.json von Hand bearbeitet,
// bekommt seine Kategorien ebenfalls eingesammelt.
s2, err := Open(s.path)
if err != nil {
t.Fatal(err)
}
for i, a := range s2.Apps() {
if a.ID != will[i] {
t.Fatalf("nach dem Neuladen %q an Stelle %d, erwartet %q", a.ID, i, will[i])
}
}
}
+55
View File
@@ -1396,3 +1396,58 @@ body {
@media (max-width: 620px) { @media (max-width: 620px) {
.deskwidgets { position: static; align-items: stretch; margin: 10px; } .deskwidgets { position: static; align-items: stretch; margin: 10px; }
} }
/* ── Kategorien ────────────────────────────────────────────────────────────
*
* Eine Überschrift je Gruppe, in derselben Sprache wie die übrigen Etiketten:
* klein, gesperrt, gedimmt. Sie soll die Kacheln gliedern, nicht mit ihnen um
* Aufmerksamkeit ringen.
*/
.tiles-kopf {
/* Bleibt ein Kind desselben Rasters und spannt über alle Spalten — ein
eigenes Raster je Gruppe hätte die Kacheln zweier Gruppen gegeneinander
verschoben. */
grid-column: 1 / -1;
margin: 6px 0 -4px;
font-size: 11px;
font-weight: normal;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.tiles-kopf:first-child { margin-top: 0; }
.applist .al-kopf {
padding: 8px 8px 3px;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.applist .al-kopf:first-child { padding-top: 2px; }
/* Im Editor trägt die Überschrift die Pfeile für die ganze Gruppe. */
.ae-gruppe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 4px 3px;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.ae-gruppe:first-of-type { padding-top: 2px; }
/* Die Schublade ist ein Raster mit drei Spalten — die Überschrift spannt
darüber, sonst stünde sie als vierte Kachel neben den Apps. */
.drawer .d-kopf {
grid-column: 1 / -1;
padding: 7px 4px 1px;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--p-dim);
}
.drawer .d-kopf:first-child { padding-top: 0; }
+27
View File
@@ -276,6 +276,15 @@
padding: 4px 8px; font-size: 10px; letter-spacing: .18em; padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
text-transform: uppercase; color: var(--p-dim); text-transform: uppercase; color: var(--p-dim);
} }
/* Ueberschrift einer Kategorie in der Schublade. Wie .head, nur mit
etwas Luft darueber, und sie spannt ueber alle drei Spalten der
Schublade — sonst stuende sie als vierte Kachel neben den Apps. */
.d-kopf {
grid-column: 1 / -1;
padding: 6px 4px 1px; font-size: 10px; letter-spacing: .18em;
text-transform: uppercase; color: var(--p-dim);
}
.d-kopf:first-child { padding-top: 0; }
/* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am /* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am
Logo links, das App-Menue am Pfeil rechts. */ Logo links, das App-Menue am Pfeil rechts. */
.menu.sys { left: 6px; right: auto; } .menu.sys { left: 6px; right: auto; }
@@ -355,6 +364,13 @@
return span; return span;
}; };
// Die Schublade bekommt Ueberschriften, der Streifen nicht: Dort sucht man
// eine App, hier erkennt man sie im Vorbeigehen wieder — und fuer eine
// Ueberschrift ist in 32 Pixeln Hoehe ohnehin kein Platz. Die Reihenfolge
// der Gruppen ist die ihres ersten Auftretens in der Liste; HoneyOS
// sortiert sie schon so, hier wird nur eingesammelt.
let letzteKategorie = null;
apps.forEach((app) => { apps.forEach((app) => {
if (app.id === "honeyos") return; // steckt schon links im Logo if (app.id === "honeyos") return; // steckt schon links im Logo
@@ -369,6 +385,17 @@
if (app.id === current) a.setAttribute("aria-current", "page"); if (app.id === current) a.setAttribute("aria-current", "page");
nav.appendChild(a); nav.appendChild(a);
const kat = (app.kategorie || "").trim();
if (kat !== letzteKategorie) {
letzteKategorie = kat;
if (kat) {
const kopf = document.createElement("div");
kopf.className = "d-kopf";
kopf.textContent = kat;
menuApps.appendChild(kopf);
}
}
const m = document.createElement("a"); const m = document.createElement("a");
m.className = "d-app"; m.className = "d-app";
m.href = app.url; m.href = app.url;
+30
View File
@@ -123,3 +123,33 @@ export function applySettings() {
r.dataset.glow = state.settings.glow; r.dataset.glow = state.settings.glow;
r.dataset.mode = state.settings.mode; r.dataset.mode = state.settings.mode;
} }
/* ── Kategorien ────────────────────────────────────────────────────────────
*
* Apps tragen eine Kategorie als freien Text. Eine Liste von Kategorien gibt
* es nicht: Eine Kategorie besteht, solange eine App sie trägt. Deshalb ist
* dies auch keine Sortierung, sondern nur ein Einsammeln — die Reihenfolge
* der Gruppen ist die ihres ersten Auftretens, die Reihenfolge innerhalb
* einer Gruppe die der App-Liste. Damit ordnen dieselben Pfeile wie bisher
* beides zugleich, und es gibt nichts, was auseinanderlaufen könnte.
*
* Apps ohne Kategorie bilden die erste Gruppe, und die trägt keine
* Überschrift: Wer nie eine Kategorie vergibt, sieht alles wie zuvor.
*/
export function gruppiere(apps) {
const gruppen = [];
const nach = new Map();
for (const app of apps) {
const k = (app.kategorie || "").trim();
if (!nach.has(k)) {
const g = { kategorie: k, apps: [] };
nach.set(k, g);
gruppen.push(g);
}
nach.get(k).apps.push(app);
}
// Ohne Kategorie zuerst — das ist der Stand von vorher, und der gehört nach
// oben, nicht dahin, wo zufällig die erste unbenannte App steht.
gruppen.sort((a, b) => (a.kategorie === "" ? -1 : b.kategorie === "" ? 1 : 0));
return gruppen;
}
+122 -14
View File
@@ -1,5 +1,5 @@
import { 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, applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
SS_BOOTED, state, SS_BOOTED, state,
} from "./core.js"; } from "./core.js";
@@ -285,7 +285,7 @@ function renderTiles() {
const wrap = $("tiles"); const wrap = $("tiles");
wrap.innerHTML = ""; wrap.innerHTML = "";
state.cfg.apps.forEach((app) => { const kachel = (app) => {
const a = document.createElement("a"); const a = document.createElement("a");
a.className = "tile"; a.className = "tile";
a.href = app.url; a.href = app.url;
@@ -300,7 +300,25 @@ function renderTiles() {
e.preventDefault(); e.preventDefault();
launch(app); 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"); const add = document.createElement("button");
@@ -512,13 +530,19 @@ function openSystemWindow() {
} }
function openProgramsWindow() { function openProgramsWindow() {
const rows = state.cfg.apps const rows = gruppiere(state.cfg.apps)
.map( .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 class="al-glyph">${glyphSvg(a.glyph)}</span>
<span>${escapeHtml(a.name)}</span> <span>${escapeHtml(a.name)}</span>
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span> <span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
</button>` </button>`
)
.join("")
) )
.join(""); .join("");
@@ -766,6 +790,12 @@ function widgetListeZeichnen(ul) {
* 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.
*/ */
// 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 })); 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> <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>
@@ -808,16 +841,56 @@ 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();
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 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>`; : `<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; 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) => list.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => { b.addEventListener("click", () => {
@@ -836,13 +909,25 @@ function openApps() {
// 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.
//
// 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) => { const tausche = (i, j) => {
if (j < 0 || j >= draft.length) return; 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); render(root);
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken // Zurueck auf denselben Knopf, damit man mehrmals hintereinander druecken
// kann, ohne die Maus nachzuführen. // kann, ohne die Maus nachzufuehren.
const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${j}"]`); const wieder = list.querySelector(`[data-${j < i ? "up" : "down"}="${draft.indexOf(a)}"]`);
if (wieder && !wieder.disabled) wieder.focus(); if (wieder && !wieder.disabled) wieder.focus();
}; };
@@ -854,6 +939,21 @@ 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(
@@ -870,7 +970,9 @@ function openApps() {
render(root); render(root);
root.querySelector("#btnAdd").addEventListener("click", () => { 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 }); draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
render(root); render(root);
}); });
@@ -951,14 +1053,20 @@ function buildAppMenu() {
`<div class="sm-head">wechseln zu</div>` + `<div class="sm-head">wechseln zu</div>` +
(state.cfg.apps.length (state.cfg.apps.length
? `<div class="drawer">` + ? `<div class="drawer">` +
state.cfg.apps gruppiere(state.cfg.apps)
.map( .map(
(a) => (g) =>
`<button class="d-app" data-app="${escapeAttr(a.id)}"> (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-ico">${glyphSvg(a.glyph)}</span>
<span class="d-name">${escapeHtml(a.name)}</span> <span class="d-name">${escapeHtml(a.name)}</span>
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""} ${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
</button>` </button>`
)
.join("")
) )
.join("") + .join("") +
`</div>` `</div>`