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:
1 parent
3b47003492
commit
501c3b8930
8 files changed
+394
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+48
-2
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+122
-14
@@ -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 & 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">▲</button>
|
||||
<button class="ae-arrow" data-gdown="${gi}" ${gi === gruppen.length - 1 ? "disabled" : ""}
|
||||
title="Gruppe nach unten" aria-label="${escapeAttr(g.kategorie)} nach unten">▼</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>`
|
||||
|
||||
Reference in new issue
Block a user