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 <noreply@anthropic.com>
This commit is contained in:
1 parent
501c3b8930
commit
bf5b7f8e63
4 files changed
+177
-134
No files matched your search
+31
-5
@@ -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. */
|
||||
|
||||
+106
-103
@@ -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) => `
|
||||
<span class="ae-move">
|
||||
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
|
||||
title="nach oben" aria-label="${escapeAttr(was)} nach oben">▲</button>
|
||||
<button class="ae-arrow" data-down="${i}" ${i === zeilen.length - 1 ? "disabled" : ""}
|
||||
title="nach unten" aria-label="${escapeAttr(was)} nach unten">▼</button>
|
||||
</span>`;
|
||||
|
||||
const kopfHtml = (z, i) => `
|
||||
<div class="ae-gruppe">
|
||||
<input class="ae-kat" data-k="${i}" value="${escapeAttr(z.name)}"
|
||||
placeholder="kategorie" aria-label="Name der Kategorie" />
|
||||
<button class="ae-kat-weg" data-kdel="${i}" title="Kategorie auflösen"
|
||||
aria-label="Kategorie ${escapeAttr(z.name)} auflösen">×</button>
|
||||
${pfeile(i, z.name || "kategorie")}
|
||||
</div>`;
|
||||
|
||||
const rowHtml = (a, i) => `
|
||||
<details class="approw" ${a.__offen ? "open" : ""}>
|
||||
<summary>
|
||||
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
|
||||
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
|
||||
<span class="ae-url">${escapeHtml(a.url || "")}</span>
|
||||
<span class="ae-move">
|
||||
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
|
||||
title="nach oben" aria-label="${escapeAttr(a.name || a.id)} nach oben">▲</button>
|
||||
<button class="ae-arrow" data-down="${i}" ${i === draft.length - 1 ? "disabled" : ""}
|
||||
title="nach unten" aria-label="${escapeAttr(a.name || a.id)} nach unten">▼</button>
|
||||
</span>
|
||||
${pfeile(i, a.name || a.id)}
|
||||
</summary>
|
||||
<div class="ae-body">
|
||||
<label class="ae-field"><span>ID</span>
|
||||
@@ -826,9 +873,6 @@ 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>
|
||||
@@ -841,93 +885,61 @@ 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
|
||||
? `<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("")
|
||||
list.innerHTML = zeilen.length
|
||||
? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).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;
|
||||
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 <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.
|
||||
// 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() {
|
||||
`<div class="apped"><div id="appList"></div>
|
||||
<div class="modal-actions">
|
||||
<span class="saveline" id="appMsg"></span>
|
||||
<button class="btn" id="btnKat">+ kategorie</button>
|
||||
<button class="btn" id="btnAdd">+ neue app</button>
|
||||
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
||||
</div>
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user