diff --git a/IDEAS.md b/IDEAS.md index d2dfba8..50f957b 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -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 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) **Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein diff --git a/README.md b/README.md index 2834511..299a40e 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,47 @@ 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 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 dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung zurück, nicht als kaputter Eintrag. diff --git a/internal/store/store.go b/internal/store/store.go index f73ab5d..df8bf0b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -33,6 +33,13 @@ type App struct { Subtitle string `json:"subtitle,omitempty"` URL string `json:"url"` 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 { @@ -94,6 +101,10 @@ func Open(path string) (*Store, error) { if s.d.Apps == nil { 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 { s.d.Users = map[string]*User{} } @@ -137,6 +148,7 @@ func (s *Store) SetApps(apps []App) error { a.Subtitle = strings.TrimSpace(a.Subtitle) a.URL = strings.TrimSpace(a.URL) a.Glyph = strings.TrimSpace(a.Glyph) + a.Kategorie = strings.TrimSpace(a.Kategorie) if !idPattern.MatchString(a.ID) { 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] { 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 { 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() defer s.mu.Unlock() - s.d.Apps = cleaned + s.d.Apps = sortiereNachKategorie(cleaned) 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 { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1c5fd86 --- /dev/null +++ b/internal/store/store_test.go @@ -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]) + } + } +} diff --git a/web/css/style.css b/web/css/style.css index d3026bd..5bebf19 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -1396,3 +1396,58 @@ body { @media (max-width: 620px) { .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; } diff --git a/web/honeybar/honeybar.js b/web/honeybar/honeybar.js index b57098f..60b0375 100644 --- a/web/honeybar/honeybar.js +++ b/web/honeybar/honeybar.js @@ -276,6 +276,15 @@ padding: 4px 8px; font-size: 10px; letter-spacing: .18em; 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 Logo links, das App-Menue am Pfeil rechts. */ .menu.sys { left: 6px; right: auto; } @@ -355,6 +364,13 @@ 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) => { if (app.id === "honeyos") return; // steckt schon links im Logo @@ -369,6 +385,17 @@ if (app.id === current) a.setAttribute("aria-current", "page"); 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"); m.className = "d-app"; m.href = app.url; diff --git a/web/js/core.js b/web/js/core.js index eb59e81..29f3e33 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -123,3 +123,33 @@ export function applySettings() { r.dataset.glow = state.settings.glow; 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; +} diff --git a/web/js/os.js b/web/js/os.js index 5b9e5e2..1eac614 100644 --- a/web/js/os.js +++ b/web/js/os.js @@ -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) => `` + ) + .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() { + +

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