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>
1357 lines
50 KiB
JavaScript
1357 lines
50 KiB
JavaScript
import {
|
|
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, gruppiere, isTouch,
|
|
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
|
|
SS_BOOTED, state,
|
|
} from "./core.js";
|
|
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
|
import { BIENE_ASCII, BIENE_SVG } from "./biene.js";
|
|
import {
|
|
widgetPlan, widgetPlanSetzen, widgetsLaden, widgetsOrdnen, widgetsStarten, widgetsZeichnen,
|
|
} from "./widgets/rahmen.js";
|
|
import "./widgets/user.js";
|
|
import { statsHtml } from "./widgets/system.js";
|
|
import { loadWeather, renderWeatherBar } from "./widgets/weather.js";
|
|
import "./widgets/notiz.js";
|
|
import { bildFormularHtml, bildFormularMontieren } from "./widgets/bild.js";
|
|
|
|
// Die Reihenfolge steht hier und nicht in den Importzeilen darüber: Module
|
|
// werden nicht in deren Reihenfolge ausgewertet, sobald eines auf oberster
|
|
// Ebene wartet (glyphs.js holt seine Raster mit await). Bis die Reihenfolge
|
|
// aus dem Server kommt, ist das ihre Stelle.
|
|
widgetsOrdnen(["benutzer", "system", "wetter", "notiz", "bild"]);
|
|
|
|
/*
|
|
* HoneyOS — Desktop-Logik.
|
|
*
|
|
* Kein Build, kein Framework: die Datei wird so ausgeliefert wie sie hier steht.
|
|
* Zustand lebt an genau zwei Stellen — `state.cfg` (kommt aus config.json, die
|
|
* beim Containerstart aus der .env entsteht) und `state.settings` (nur Browser). Beides landet in
|
|
* localStorage, damit das Gerät sich sein HoneyOS selbst einrichten kann, ohne
|
|
* dass etwas neu deployt werden muss.
|
|
*
|
|
* ZWEI BETRIEBSARTEN, EIN DATENSTAND:
|
|
* terminal — Kacheln, Tastatur, alles auf einer Fläche (der ursprüngliche Look)
|
|
* desktop — Symbole, verschiebbare Fenster, Taskleiste unten
|
|
* Beide zeichnen aus denselben `state.cfg.apps`. Was sich unterscheidet, ist
|
|
* ausschließlich die Darstellung — Menüleiste oben, Einstellungen, Palette und
|
|
* Erreichbarkeitsprüfung sind in beiden dieselben. Deshalb liegen die
|
|
* gemeinsamen Teile hier oben und die beiden Ansichten weiter unten getrennt.
|
|
*/
|
|
|
|
|
|
|
|
|
|
/* ── Laden & Speichern ─────────────────────────────────────────────────── */
|
|
|
|
/** Holt Nutzer, Einstellungen und Apps. Wirft bei fehlender Anmeldung. */
|
|
async function loadFromServer() {
|
|
const me = await api("/api/me");
|
|
state.me = { sub: me.sub, name: me.name, picture: me.picture };
|
|
if (me.version) state.cfg.version = me.version;
|
|
state.oidc = !!me.oidc;
|
|
state.cfg.user = me.name || "";
|
|
|
|
// Der Server hat das letzte Wort; localStorage war nur der Vorschuss.
|
|
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}), ...(me.settings || {}) };
|
|
state.cfg.apps = (await api("/api/apps")).apps;
|
|
}
|
|
|
|
/** Einstellungen vorab aus localStorage, damit die Röhre nicht erst weiß
|
|
* aufblitzt und dann umspringt. */
|
|
|
|
|
|
|
|
/* ── Boot ──────────────────────────────────────────────────────────────── */
|
|
|
|
function bootLines() {
|
|
const apps = state.cfg.apps;
|
|
return [
|
|
{ t: `HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`, d: 90 },
|
|
{ t: "", d: 40 },
|
|
{ t: "prüfe speicher".padEnd(28, ".") + " 640K ok", d: 120 },
|
|
{ t: "prüfe anmeldung".padEnd(28, ".") + " " + (state.cfg.user || "—"), d: 120 },
|
|
...apps.map((a) => ({ t: `binde ${a.id} ein`.padEnd(28, ".") + " [ ok ]", d: 140 })),
|
|
{ t: "", d: 60 },
|
|
{ t: `starte ${state.settings.mode}-modus`.padEnd(28, ".") + " bereit.", d: 200 },
|
|
];
|
|
}
|
|
|
|
function runBoot() {
|
|
return new Promise((resolve) => {
|
|
const log = $("bootLog");
|
|
const el = $("boot");
|
|
// Über dem Protokoll die Biene — auch hier aus Schriftzeichen, denn der
|
|
// Startlauf ist Text und nichts anderes.
|
|
$("bootBiene").textContent = BIENE_ASCII;
|
|
el.hidden = false;
|
|
const lines = bootLines();
|
|
let i = 0;
|
|
let done = false;
|
|
let timer;
|
|
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
window.removeEventListener("keydown", finish);
|
|
el.removeEventListener("click", finish);
|
|
el.hidden = true;
|
|
try {
|
|
sessionStorage.setItem(SS_BOOTED, "1");
|
|
} catch {
|
|
/* egal — dann bootet es halt jedes Mal */
|
|
}
|
|
resolve();
|
|
};
|
|
|
|
const step = () => {
|
|
if (i >= lines.length) {
|
|
timer = setTimeout(finish, 420);
|
|
return;
|
|
}
|
|
const line = lines[i++];
|
|
log.textContent += line.t + "\n";
|
|
log.classList.add("caret");
|
|
timer = setTimeout(step, line.d);
|
|
};
|
|
|
|
window.addEventListener("keydown", finish);
|
|
el.addEventListener("click", finish);
|
|
step();
|
|
});
|
|
}
|
|
|
|
/* ── Gemeinsames: Menüleiste, Uhr, Stand, Start ────────────────────────── */
|
|
|
|
|
|
function renderShell() {
|
|
$("desktop").hidden = false;
|
|
// Das Wappen steht im Markup, nicht als Bilddatei — dieselbe Zeile wie in
|
|
// der HoneyBar fremder Apps, damit beide Leisten wirklich dasselbe zeigen.
|
|
$("logoBiene").innerHTML = BIENE_SVG;
|
|
renderMenubarApps();
|
|
renderMode();
|
|
tickClock();
|
|
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
|
|
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
|
|
// in dem der Server seinen Zwischenspeicher erneuert.
|
|
// Nicht abgewartet: Die Widgets holen ihre Daten von außen und dürfen den
|
|
// Aufbau des Schreibtischs nicht verzögern. Den Takt fürs Nachladen kennt
|
|
// jedes Widget selbst.
|
|
widgetsLaden().then(renderWeatherBar);
|
|
widgetsStarten();
|
|
}
|
|
|
|
function renderMenubarApps() {
|
|
const nav = $("mbApps");
|
|
nav.innerHTML = "";
|
|
// Derselbe Schalter wie in der HoneyBar fremder Apps: Wer die Symbole dort
|
|
// abstellt, will sie hier auch nicht — es ist dieselbe Leiste.
|
|
const mitSymbol = state.settings.barGlyphs !== "off";
|
|
state.cfg.apps.forEach((app) => {
|
|
const b = document.createElement("button");
|
|
b.className = "mb-app";
|
|
if (mitSymbol) {
|
|
const ico = document.createElement("span");
|
|
ico.className = "mb-ico";
|
|
ico.innerHTML = glyphSvg(app.glyph);
|
|
b.appendChild(ico);
|
|
}
|
|
b.appendChild(document.createTextNode(app.name));
|
|
b.title = `${app.name} öffnen — ${app.url}`;
|
|
b.addEventListener("click", () => launch(app));
|
|
nav.appendChild(b);
|
|
});
|
|
}
|
|
|
|
/** Schaltet zwischen den Betriebsarten um. Beide Ansichten werden bei jedem
|
|
* Wechsel frisch gezeichnet — offene Fenster überleben ihn bewusst nicht. */
|
|
function renderMode() {
|
|
const desktopMode = state.settings.mode === "desktop";
|
|
|
|
$("stage").hidden = desktopMode;
|
|
$("statusbar").hidden = desktopMode;
|
|
$("deskspace").hidden = !desktopMode;
|
|
$("taskbar").hidden = !desktopMode;
|
|
|
|
if (desktopMode) {
|
|
renderDeskIcons();
|
|
// Ohne offenes Fenster wäre der Schreibtisch leer — das Systemfenster
|
|
// ist der sinnvollste erste Anblick. Am Handy nicht: dort deckt jedes
|
|
// Fenster sofort die halbe Symbolspalte zu.
|
|
if (!Object.keys(state.wins).length && window.innerWidth > 620) openSystemWindow();
|
|
updateTaskbar();
|
|
} else {
|
|
closeAllWindows();
|
|
renderTiles();
|
|
}
|
|
widgetsZeichnen();
|
|
paintProbes();
|
|
}
|
|
|
|
|
|
|
|
function setMode(mode) {
|
|
if (state.settings.mode === mode) return;
|
|
state.settings.mode = mode;
|
|
saveSettings();
|
|
renderMode();
|
|
}
|
|
|
|
function launch(app) {
|
|
if (state.settings.openIn === "same") {
|
|
window.location.href = app.url;
|
|
} else {
|
|
window.open(app.url, "_blank", "noopener");
|
|
}
|
|
}
|
|
|
|
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
|
|
* alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */
|
|
/** Zeichnet den Systemzustand überall neu, wo er gerade steht: als Widget in
|
|
* der Spalte und, im Desktop-Modus, im Fenster aus dem Startmenü. */
|
|
function renderStats() {
|
|
widgetsZeichnen();
|
|
document.querySelectorAll("#windows .statlist").forEach((dl) => {
|
|
dl.outerHTML = statsHtml();
|
|
});
|
|
}
|
|
|
|
function tickClock() {
|
|
const now = new Date();
|
|
const hm = now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
|
const date = now.toLocaleDateString("de-DE", {
|
|
weekday: "short",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
$("clock").textContent = hm;
|
|
$("sbRight").textContent = date;
|
|
$("taskDate").textContent = date;
|
|
}
|
|
|
|
/* ── Erreichbarkeit ────────────────────────────────────────────────────────
|
|
* Fremde Domain heißt: die Antwort ist undurchsichtig, wir sehen keinen Status.
|
|
* Mehr als "die Kiste hat geantwortet" ist von hier aus nicht zu holen — für
|
|
* eine Lampe auf der Kachel reicht das, ein Health-Check ist es nicht.
|
|
*/
|
|
async function probeApps() {
|
|
await Promise.all(
|
|
state.cfg.apps.map(async (app) => {
|
|
state.probes[app.id] = await probe(app.url);
|
|
})
|
|
);
|
|
paintProbes();
|
|
renderStats();
|
|
}
|
|
|
|
async function probe(url) {
|
|
const ctl = new AbortController();
|
|
const t = setTimeout(() => ctl.abort(), 5000);
|
|
try {
|
|
await fetch(url, { mode: "no-cors", cache: "no-store", signal: ctl.signal, redirect: "follow" });
|
|
return "up";
|
|
} catch {
|
|
return "down";
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
const STATE_TEXT = { up: "online", down: "keine antwort", unknown: "prüfe …" };
|
|
|
|
function stateLine(appId) {
|
|
const s = state.probes[appId] || "unknown";
|
|
return { s, text: STATE_TEXT[s] };
|
|
}
|
|
|
|
function paintProbes() {
|
|
document.querySelectorAll(".tile-state").forEach((el) => {
|
|
const { s, text } = stateLine(el.dataset.app);
|
|
el.dataset.state = s;
|
|
el.innerHTML = `<i class="led"></i>${escapeHtml(text)}`;
|
|
});
|
|
document.querySelectorAll(".deskicon[data-app]").forEach((el) => {
|
|
const { s, text } = stateLine(el.dataset.app);
|
|
el.dataset.state = s;
|
|
el.title = `${el.dataset.name} — ${text}`;
|
|
});
|
|
}
|
|
|
|
/* ══ Ansicht 1: Terminal-Modus ═══════════════════════════════════════════ */
|
|
|
|
function renderTiles() {
|
|
const wrap = $("tiles");
|
|
wrap.innerHTML = "";
|
|
|
|
const kachel = (app) => {
|
|
const a = document.createElement("a");
|
|
a.className = "tile";
|
|
a.href = app.url;
|
|
a.target = state.settings.openIn === "tab" ? "_blank" : "_self";
|
|
a.rel = "noopener";
|
|
a.innerHTML = `
|
|
<span class="tile-icon">${glyphSvg(app.glyph)}</span>
|
|
<span class="tile-name">${escapeHtml(app.name)}</span>
|
|
<span class="tile-sub">${escapeHtml(app.subtitle || "")}</span>
|
|
<span class="tile-state" data-app="${app.id}" data-state="unknown"><i class="led"></i>prüfe …</span>`;
|
|
a.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
launch(app);
|
|
});
|
|
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");
|
|
add.className = "tile tile-add";
|
|
add.innerHTML = state.cfg.apps.length
|
|
? "<span>+ app hinzufügen</span>"
|
|
: "<span>Noch keine App.<br />Hier die erste anlegen.</span>";
|
|
add.addEventListener("click", openApps);
|
|
wrap.appendChild(add);
|
|
}
|
|
|
|
/* ══ Ansicht 2: Desktop-Modus ════════════════════════════════════════════ */
|
|
|
|
function renderDeskIcons() {
|
|
const wrap = $("deskIcons");
|
|
wrap.innerHTML = "";
|
|
|
|
const items = [
|
|
...state.cfg.apps.map((app) => ({
|
|
id: app.id,
|
|
name: app.name,
|
|
glyph: app.glyph,
|
|
app,
|
|
run: () => launch(app),
|
|
})),
|
|
{ id: "programme", name: "Programme", glyph: "folder", run: openProgramsWindow },
|
|
{ id: "system", name: "System", glyph: "chip", run: openSystemWindow },
|
|
{ id: "settings", name: "Einstellungen", glyph: "gear", run: openSettings },
|
|
];
|
|
|
|
items.forEach((item) => {
|
|
const b = document.createElement("button");
|
|
b.className = "deskicon";
|
|
b.dataset.name = item.name;
|
|
if (item.app) b.dataset.app = item.app.id;
|
|
b.innerHTML = `<span class="di-glyph">${glyphSvg(item.glyph)}</span><span class="di-label">${escapeHtml(item.name)}</span>`;
|
|
|
|
// Am Zeigegerät wie früher: einmal auswählen, zweimal öffnen.
|
|
// Am Finger wäre das eine Zumutung — dort öffnet die erste Berührung.
|
|
b.addEventListener("click", () => {
|
|
selectIcon(b);
|
|
if (isTouch()) item.run();
|
|
});
|
|
b.addEventListener("dblclick", item.run);
|
|
b.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
item.run();
|
|
}
|
|
});
|
|
wrap.appendChild(b);
|
|
});
|
|
|
|
wrap.addEventListener("pointerdown", (e) => {
|
|
if (e.target === wrap) selectIcon(null);
|
|
});
|
|
}
|
|
|
|
function selectIcon(el) {
|
|
document.querySelectorAll(".deskicon[data-sel]").forEach((i) => delete i.dataset.sel);
|
|
if (el) el.dataset.sel = "1";
|
|
}
|
|
|
|
/* ── Fenster ───────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Legt ein Fenster an oder holt ein vorhandenes nach vorn.
|
|
* `body` ist fertiges HTML, `onMount` bekommt den Rumpf zum Verdrahten.
|
|
*/
|
|
function openWindow({ id, title, body, w = 340, x, y, onMount }) {
|
|
const existing = state.wins[id];
|
|
if (existing) {
|
|
existing.min = false;
|
|
delete existing.el.dataset.min;
|
|
focusWindow(id);
|
|
updateTaskbar();
|
|
return existing.el;
|
|
}
|
|
|
|
const layer = $("windows");
|
|
const bounds = layer.getBoundingClientRect();
|
|
const width = Math.min(w, Math.max(200, bounds.width - 24));
|
|
const count = Object.keys(state.wins).length;
|
|
|
|
const el = document.createElement("section");
|
|
el.className = "dwin";
|
|
el.style.width = width + "px";
|
|
// Links liegt die Symbolspalte — Fenster starten rechts davon, sonst deckt
|
|
// das erste Fenster gleich die App-Symbole zu.
|
|
const startX = bounds.width > 420 ? 124 : 8;
|
|
el.style.left = (x ?? Math.min(startX + count * 26, Math.max(8, bounds.width - width - 8))) + "px";
|
|
el.style.top = (y ?? 18 + count * 24) + "px";
|
|
el.innerHTML = `
|
|
<div class="win-bar">
|
|
<span class="win-title">${escapeHtml(title)}</span>
|
|
<span class="win-btns">
|
|
<button class="win-btn" data-act="min" title="In die Taskleiste">−</button>
|
|
<button class="win-btn" data-act="close" title="Schließen">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="win-body">${body}</div>`;
|
|
|
|
layer.appendChild(el);
|
|
state.wins[id] = { el, min: false, title };
|
|
|
|
el.querySelector('[data-act="close"]').addEventListener("click", () => closeWindow(id));
|
|
el.querySelector('[data-act="min"]').addEventListener("click", () => minimizeWindow(id));
|
|
el.addEventListener("pointerdown", () => focusWindow(id));
|
|
makeDraggable(el, el.querySelector(".win-bar"), layer);
|
|
|
|
if (onMount) onMount(el.querySelector(".win-body"));
|
|
focusWindow(id);
|
|
updateTaskbar();
|
|
return el;
|
|
}
|
|
|
|
function focusWindow(id) {
|
|
Object.entries(state.wins).forEach(([wid, w]) => (w.el.dataset.focus = wid === id ? "1" : "0"));
|
|
const w = state.wins[id];
|
|
if (w) w.el.style.zIndex = String(++state.zTop);
|
|
updateTaskbar();
|
|
}
|
|
|
|
function closeWindow(id) {
|
|
const w = state.wins[id];
|
|
if (!w) return;
|
|
w.el.remove();
|
|
delete state.wins[id];
|
|
updateTaskbar();
|
|
}
|
|
|
|
function closeAllWindows() {
|
|
Object.keys(state.wins).forEach(closeWindow);
|
|
}
|
|
|
|
function minimizeWindow(id) {
|
|
const w = state.wins[id];
|
|
if (!w) return;
|
|
w.min = true;
|
|
w.el.dataset.min = "1";
|
|
updateTaskbar();
|
|
}
|
|
|
|
/** Ziehen am Titelbalken. Pointer Events, damit Maus und Finger denselben
|
|
* Weg nehmen; die Position wird im sichtbaren Bereich gehalten. */
|
|
function makeDraggable(win, bar, layer) {
|
|
bar.addEventListener("pointerdown", (e) => {
|
|
if (e.target.closest(".win-btn")) return;
|
|
const wr = win.getBoundingClientRect();
|
|
const lr = layer.getBoundingClientRect();
|
|
const offX = e.clientX - wr.left;
|
|
const offY = e.clientY - wr.top;
|
|
win.dataset.drag = "1";
|
|
bar.setPointerCapture(e.pointerId);
|
|
|
|
const move = (ev) => {
|
|
const x = Math.max(0, Math.min(ev.clientX - lr.left - offX, lr.width - wr.width));
|
|
const y = Math.max(0, Math.min(ev.clientY - lr.top - offY, lr.height - 26));
|
|
win.style.left = x + "px";
|
|
win.style.top = y + "px";
|
|
};
|
|
const up = () => {
|
|
delete win.dataset.drag;
|
|
bar.removeEventListener("pointermove", move);
|
|
bar.removeEventListener("pointerup", up);
|
|
bar.removeEventListener("pointercancel", up);
|
|
};
|
|
bar.addEventListener("pointermove", move);
|
|
bar.addEventListener("pointerup", up);
|
|
bar.addEventListener("pointercancel", up);
|
|
});
|
|
}
|
|
|
|
function updateTaskbar() {
|
|
const list = $("taskList");
|
|
if (!list) return;
|
|
list.innerHTML = "";
|
|
Object.entries(state.wins).forEach(([id, w]) => {
|
|
const b = document.createElement("button");
|
|
b.className = "task-btn";
|
|
b.textContent = w.title;
|
|
b.dataset.active = !w.min && w.el.dataset.focus === "1" ? "1" : "0";
|
|
b.addEventListener("click", () => {
|
|
if (w.min) {
|
|
w.min = false;
|
|
delete w.el.dataset.min;
|
|
focusWindow(id);
|
|
} else if (w.el.dataset.focus === "1") {
|
|
minimizeWindow(id);
|
|
} else {
|
|
focusWindow(id);
|
|
}
|
|
});
|
|
list.appendChild(b);
|
|
});
|
|
}
|
|
|
|
/* ── Fensterinhalte ────────────────────────────────────────────────────── */
|
|
|
|
function openSystemWindow() {
|
|
openWindow({
|
|
id: "system",
|
|
title: "system",
|
|
w: 320,
|
|
// Derselbe Inhalt wie das Widget im Terminal-Modus — eine Quelle, damit
|
|
// nicht zwei Listen mit denselben Zahlen auseinanderlaufen.
|
|
body: statsHtml(),
|
|
});
|
|
}
|
|
|
|
function openProgramsWindow() {
|
|
const rows = gruppiere(state.cfg.apps)
|
|
.map(
|
|
(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("");
|
|
|
|
openWindow({
|
|
id: "programme",
|
|
title: "programme",
|
|
w: 360,
|
|
body: `<div class="applist">${rows}
|
|
<button data-settings="1"><span class="al-glyph">${glyphSvg("gear")}</span><span>programme verwalten …</span></button>
|
|
</div>`,
|
|
onMount: (root) => {
|
|
root.querySelectorAll("[data-launch]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
const app = state.cfg.apps.find((a) => a.id === b.dataset.launch);
|
|
if (app) launch(app);
|
|
})
|
|
);
|
|
root.querySelector("[data-settings]").addEventListener("click", openApps);
|
|
},
|
|
});
|
|
}
|
|
|
|
/* ── Dialoge: Modal im Terminal, Fenster auf dem Schreibtisch ──────────── */
|
|
|
|
function openPanel(id, title, html, onMount, width) {
|
|
if (state.settings.mode === "desktop") {
|
|
openWindow({ id, title, body: html, w: width || 380, onMount });
|
|
return;
|
|
}
|
|
$("modalTitle").textContent = title;
|
|
$("modalBody").innerHTML = html;
|
|
$("modal").hidden = false;
|
|
if (onMount) onMount($("modalBody"));
|
|
}
|
|
|
|
function closeModal() {
|
|
$("modal").hidden = true;
|
|
}
|
|
|
|
function openSettings() {
|
|
const s = state.settings;
|
|
|
|
/* Ein Feld je Zeile, Beschriftung links, Schalter rechts — und die Zeilen in
|
|
* Abschnitten. Vorher stand alles untereinander in einer einzigen Spalte,
|
|
* mit Abstandshaltern zwischen den Schaltern; bei zwölf Einstellungen sieht
|
|
* das aus wie eine Liste, in der man suchen muss. */
|
|
const seg = (name, opts, cur) =>
|
|
`<div class="seg" data-seg="${name}">` +
|
|
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
|
|
`</div>`;
|
|
const zeile = (label, rechts) => `<div class="reihe"><span class="r-label">${label}</span>${rechts}</div>`;
|
|
const schalter = (name, label, anLabel = "an") =>
|
|
zeile(label, seg(name, [["on", anLabel], ["off", "aus"]], s[name]));
|
|
const sektion = (titel, inhalt) => `<section class="sek"><h4 class="sek-titel">${titel}</h4>${inhalt}</section>`;
|
|
|
|
openPanel(
|
|
"settings",
|
|
"einstellungen",
|
|
sektion(
|
|
"Darstellung",
|
|
zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
|
|
zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
|
|
schalter("scanlines", "Scanlines") +
|
|
schalter("flicker", "Flackern") +
|
|
schalter("glow", "Glühen")
|
|
) +
|
|
sektion(
|
|
"HoneyBar",
|
|
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
|
|
zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
|
|
schalter("weatherBar", "Wetter in der Leiste") +
|
|
`<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
|
|
) +
|
|
sektion(
|
|
"Widgets",
|
|
`<ul class="wl" id="wlListe"></ul>
|
|
<p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
|
|
) +
|
|
sektion(
|
|
"Bild",
|
|
bildFormularHtml() +
|
|
`<p class="sek-hinweis">Genau ein Bild je Konto; ein neues ersetzt das alte.
|
|
Zu sehen ist es im Widget „bild".</p>`
|
|
) +
|
|
sektion(
|
|
"Wetter",
|
|
`<div class="wx-city-row">
|
|
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
|
|
<button class="btn" id="btnWxSearch">suchen</button>
|
|
</div>
|
|
<div id="wxResults" class="wx-results"></div>
|
|
<p class="sek-hinweis" id="wxCurrent"></p>`
|
|
) +
|
|
sektion(
|
|
"Programme",
|
|
zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
|
|
) +
|
|
sektion(
|
|
"Konto",
|
|
`<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
|
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
|
|
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
|
|
// einer fremden App heraus nicht anklicken können soll (siehe
|
|
// honeybar.js), und von HoneyOS aus findet man es dort, wo alles
|
|
// andere auch steht.
|
|
(state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
|
|
),
|
|
(root) => {
|
|
root.querySelectorAll(".seg").forEach((segEl) => {
|
|
segEl.addEventListener("click", (e) => {
|
|
const btn = e.target.closest("button[data-v]");
|
|
if (!btn) return;
|
|
const key = segEl.dataset.seg;
|
|
segEl.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
|
|
|
|
if (key === "mode") {
|
|
// Der Moduswechsel zeichnet alles neu — die Einstellungen sollen
|
|
// dabei offen bleiben, also anschließend erneut öffnen.
|
|
setMode(btn.dataset.v);
|
|
openSettings();
|
|
return;
|
|
}
|
|
state.settings[key] = btn.dataset.v;
|
|
saveSettings();
|
|
renderStats();
|
|
if (key.startsWith("weather")) loadWeather();
|
|
if (key === "openIn" && state.settings.mode === "terminal") renderTiles();
|
|
if (key === "barGlyphs") renderMenubarApps();
|
|
});
|
|
});
|
|
|
|
widgetListeZeichnen(root.querySelector("#wlListe"));
|
|
bildFormularMontieren(root);
|
|
|
|
const zeigeOrt = () => {
|
|
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
|
|
? `eingestellt: ${state.settings.weatherCity}`
|
|
: "kein Ort eingestellt — ohne Ort bleibt das Wetter aus.";
|
|
};
|
|
zeigeOrt();
|
|
|
|
const suchen = async () => {
|
|
const q = root.querySelector("#wxQuery").value.trim();
|
|
const out = root.querySelector("#wxResults");
|
|
if (!q) return;
|
|
out.textContent = "suche …";
|
|
let orte;
|
|
try {
|
|
orte = await api("/api/weather/search?q=" + encodeURIComponent(q));
|
|
} catch {
|
|
out.textContent = "Ortssuche nicht erreichbar.";
|
|
return;
|
|
}
|
|
if (!orte || !orte.length) {
|
|
out.textContent = "nichts gefunden.";
|
|
return;
|
|
}
|
|
out.innerHTML = orte
|
|
.map((o, i) => `<button type="button" class="wx-hit" data-i="${i}">${o.name}<span>${o.land}</span></button>`)
|
|
.join("");
|
|
out.querySelectorAll(".wx-hit").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
const o = orte[Number(b.dataset.i)];
|
|
// Koordinaten mitspeichern: Sonst müsste jede Wetterabfrage den Ort
|
|
// erneut suchen — zwei Anfragen nach draußen statt einer.
|
|
state.settings.weatherCity = o.name;
|
|
state.settings.weatherLat = String(o.lat);
|
|
state.settings.weatherLon = String(o.lon);
|
|
saveSettings();
|
|
out.innerHTML = "";
|
|
root.querySelector("#wxQuery").value = "";
|
|
zeigeOrt();
|
|
loadWeather();
|
|
})
|
|
);
|
|
};
|
|
root.querySelector("#btnWxSearch").addEventListener("click", suchen);
|
|
root.querySelector("#wxQuery").addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") { e.preventDefault(); suchen(); }
|
|
});
|
|
|
|
root.querySelector("#btnApps").addEventListener("click", openApps);
|
|
root.querySelector("#btnLogout")?.addEventListener("click", logout);
|
|
},
|
|
420
|
|
);
|
|
}
|
|
|
|
/* ── Die Widget-Liste in den Einstellungen ─────────────────────────────────
|
|
*
|
|
* Dieselbe Bedienung wie bei den Apps: Pfeile statt Ziehen. Ziehen wäre hübsch,
|
|
* aber am Handy ist es fummelig, und die Liste ist kurz.
|
|
*
|
|
* Der Rahmen kennt die Widgets, nicht diese Datei — hier wird nur gezeigt, was
|
|
* er hergibt, und zurückgegeben, was der Nutzer daraus gemacht hat.
|
|
*/
|
|
function widgetListeZeichnen(ul) {
|
|
if (!ul) return;
|
|
const plan = widgetPlan();
|
|
|
|
ul.innerHTML = plan
|
|
.map(
|
|
(p, i) => `
|
|
<li class="wl-zeile${p.an ? "" : " wl-aus"}">
|
|
<button type="button" class="wl-an" data-an="${i}" aria-pressed="${p.an}">${p.an ? "an" : "aus"}</button>
|
|
<span class="wl-name">${escapeHtml(p.titel)}${
|
|
p.wo && p.wo.length === 1 ? `<em>nur ${p.wo[0]}</em>` : ""
|
|
}</span>
|
|
<span class="ae-move">
|
|
<button type="button" class="ae-arrow" data-hoch="${i}" ${i === 0 ? "disabled" : ""}>▲</button>
|
|
<button type="button" class="ae-arrow" data-runter="${i}" ${
|
|
i === plan.length - 1 ? "disabled" : ""
|
|
}>▼</button>
|
|
</span>
|
|
</li>`
|
|
)
|
|
.join("");
|
|
|
|
const uebernehmen = () => {
|
|
widgetPlanSetzen(plan);
|
|
widgetListeZeichnen(ul);
|
|
};
|
|
|
|
ul.querySelectorAll("[data-an]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
const p = plan[Number(b.dataset.an)];
|
|
p.an = !p.an;
|
|
uebernehmen();
|
|
})
|
|
);
|
|
const tausche = (i, j) => {
|
|
[plan[i], plan[j]] = [plan[j], plan[i]];
|
|
uebernehmen();
|
|
};
|
|
ul.querySelectorAll("[data-hoch]").forEach((b) =>
|
|
b.addEventListener("click", () => tausche(Number(b.dataset.hoch), Number(b.dataset.hoch) - 1))
|
|
);
|
|
ul.querySelectorAll("[data-runter]").forEach((b) =>
|
|
b.addEventListener("click", () => tausche(Number(b.dataset.runter), Number(b.dataset.runter) + 1))
|
|
);
|
|
}
|
|
|
|
/* ── App-Verwaltung ────────────────────────────────────────────────────────
|
|
* 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.
|
|
*/
|
|
// 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 }));
|
|
|
|
// 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
|
|
// HoneyBar braucht beides in einer Antwort), taugen aber nicht als App-Logo.
|
|
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
|
|
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
|
|
|
|
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>
|
|
</summary>
|
|
<div class="ae-body">
|
|
<label class="ae-field"><span>ID</span>
|
|
<input data-i="${i}" data-f="id" value="${escapeAttr(a.id || "")}" placeholder="aether" spellcheck="false" /></label>
|
|
<p class="ae-hint">klein, ohne Leerzeichen — muss zu <code>data-app</code> der HoneyBar passen</p>
|
|
<label class="ae-field"><span>Name</span>
|
|
<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>
|
|
<select data-i="${i}" data-f="glyph">
|
|
${GLYPHS_LIST.map((g) => `<option value="${g}" ${a.glyph === g ? "selected" : ""}>${g}</option>`).join("")}
|
|
</select></label>
|
|
<div class="ae-actions">
|
|
<button class="btn btn-danger" data-del="${i}">löschen</button>
|
|
</div>
|
|
</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("")
|
|
: `<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", () => {
|
|
draft.splice(Number(b.dataset.del), 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) => {
|
|
d.addEventListener("toggle", () => {
|
|
if (draft[i]) draft[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.
|
|
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 || "";
|
|
}
|
|
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)}"]`);
|
|
if (wieder && !wieder.disabled) wieder.focus();
|
|
};
|
|
|
|
list.querySelectorAll("[data-up], [data-down]").forEach((b) =>
|
|
b.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const i = Number(b.dataset.up ?? b.dataset.down);
|
|
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(
|
|
"apps",
|
|
"programme verwalten",
|
|
`<div class="apped"><div id="appList"></div>
|
|
<div class="modal-actions">
|
|
<span class="saveline" id="appMsg"></span>
|
|
<button class="btn" id="btnAdd">+ neue app</button>
|
|
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
|
</div>
|
|
</div>`,
|
|
(root) => {
|
|
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 });
|
|
render(root);
|
|
});
|
|
|
|
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) }));
|
|
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
|
|
render(root);
|
|
renderMenubarApps();
|
|
renderMode();
|
|
probeApps();
|
|
} catch (e) {
|
|
msg.dataset.kind = "err";
|
|
msg.textContent = e.message;
|
|
}
|
|
});
|
|
},
|
|
460
|
|
);
|
|
}
|
|
|
|
function openAbout() {
|
|
openPanel(
|
|
"about",
|
|
"über honeyos",
|
|
`<div class="about">
|
|
<p><b>HoneyOS ${escapeHtml(state.cfg.version)}</b> — eine Oberfläche für mehrere eigene Apps.
|
|
Jede App bleibt ihre eigene Seite auf ihrer eigenen Domain; HoneyOS ist der Schreibtisch
|
|
davor und die HoneyBar darin.</p>
|
|
<p>Zwei Betriebsarten, ein Datenstand: <code>terminal</code> zeigt Kacheln,
|
|
<code>desktop</code> Symbole und Fenster. Die HoneyBar oben ist in beiden dieselbe —
|
|
und dieselbe steckt per <code>honeybar/honeybar.js</code> auch in Aether und InkNote.</p>
|
|
<dl class="keys">
|
|
<dt>M</dt><dd>Betriebsart wechseln</dd>
|
|
<dt>S</dt><dd>Einstellungen</dd>
|
|
<dt>?</dt><dd>diese Hilfe</dd>
|
|
<dt>Esc</dt><dd>Fenster schließen</dd>
|
|
</dl>
|
|
</div>`
|
|
);
|
|
}
|
|
|
|
/* ── Hilfsmittel ───────────────────────────────────────────────────────── */
|
|
|
|
|
|
/* ── App-Menü im Pfeil rechts ──────────────────────────────────────────────
|
|
* Der Zwilling des Pfeils in honeybar.js. Die Apps stehen zwar schon als
|
|
* Streifen in der Leiste, aber der schiebt bei vielen Apps seitwärts — das
|
|
* Menü zeigt sie alle untereinander. Das Zahnrad ist dafür weggefallen:
|
|
* Einstellungen stehen im Systemmenü links, wie in jeder App.
|
|
*/
|
|
|
|
function buildAppMenu() {
|
|
const menu = document.createElement("div");
|
|
menu.className = "startmenu appmenu";
|
|
menu.id = "appmenu";
|
|
$("desktop").appendChild(menu);
|
|
|
|
const btn = $("btnAppMenu");
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (menu.dataset.open === "1") return closeAppMenu();
|
|
closeSystemMenu();
|
|
closeStartMenu();
|
|
|
|
// Dieselbe Schublade wie in honeybar.js: Symbol, Name, Untertitel. Sie wird
|
|
// absichtlich aufgemacht, weil man etwas sucht — also wird gelesen, nicht
|
|
// nur wiedererkannt. Im Streifen oben reicht ein Blick, dort ist der Name
|
|
// abschaltbar; hier steht er immer.
|
|
menu.innerHTML =
|
|
`<div class="sm-head">wechseln zu</div>` +
|
|
(state.cfg.apps.length
|
|
? `<div class="drawer">` +
|
|
gruppiere(state.cfg.apps)
|
|
.map(
|
|
(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>`
|
|
: `<button data-settings="1">keine app eingetragen …</button>`);
|
|
|
|
menu.querySelectorAll("[data-app]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
const app = state.cfg.apps.find((a) => a.id === b.dataset.app);
|
|
closeAppMenu();
|
|
if (app) launch(app);
|
|
})
|
|
);
|
|
menu.querySelector("[data-settings]")?.addEventListener("click", () => {
|
|
closeAppMenu();
|
|
openApps();
|
|
});
|
|
|
|
menu.dataset.open = "1";
|
|
btn.setAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
document.addEventListener("click", closeAppMenu);
|
|
}
|
|
|
|
function closeAppMenu() {
|
|
const menu = $("appmenu");
|
|
if (!menu) return;
|
|
menu.dataset.open = "0";
|
|
$("btnAppMenu").setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
/* ── Systemmenü im Logo ────────────────────────────────────────────────────
|
|
* Der Zwilling des Menüs in honeybar.js: gleiche Punkte, gleiche Reihenfolge.
|
|
* Hier sind es Handlungen, dort Links hierher — denn nur hier ist man
|
|
* angemeldet. Wer aus Aether auf „Einstellungen“ klickt, landet auf genau
|
|
* demselben Feld, das dieser Punkt aufmacht.
|
|
*/
|
|
|
|
function buildSystemMenu() {
|
|
const menu = document.createElement("div");
|
|
menu.className = "startmenu sysmenu";
|
|
menu.id = "sysmenu";
|
|
$("desktop").appendChild(menu);
|
|
|
|
const btn = $("btnSystem");
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (menu.dataset.open === "1") return closeSystemMenu();
|
|
closeStartMenu();
|
|
closeAppMenu();
|
|
|
|
const terminal = state.settings.mode === "terminal";
|
|
menu.innerHTML =
|
|
`<div class="sm-head">honeyos</div>
|
|
<button data-do="about">Über HoneyOS</button>
|
|
<button data-do="settings">Einstellungen</button>
|
|
<hr />
|
|
<button data-do="mode">${terminal ? "▣ Desktop-Modus" : "↩ Terminal-Modus"}</button>`;
|
|
|
|
menu.querySelectorAll("[data-do]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
closeSystemMenu();
|
|
({
|
|
about: openAbout,
|
|
settings: openSettings,
|
|
mode: () => setMode(terminal ? "desktop" : "terminal"),
|
|
})[b.dataset.do]();
|
|
})
|
|
);
|
|
|
|
menu.dataset.open = "1";
|
|
btn.setAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
document.addEventListener("click", closeSystemMenu);
|
|
}
|
|
|
|
function closeSystemMenu() {
|
|
const menu = $("sysmenu");
|
|
if (!menu) return;
|
|
menu.dataset.open = "0";
|
|
$("btnSystem").setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
/* ── Startmenü ─────────────────────────────────────────────────────────── */
|
|
|
|
function buildStartMenu() {
|
|
const menu = document.createElement("div");
|
|
menu.className = "startmenu";
|
|
menu.id = "startmenu";
|
|
$("desktop").appendChild(menu);
|
|
|
|
const btn = $("btnStart");
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const open = menu.dataset.open === "1";
|
|
if (open) return closeStartMenu();
|
|
// Zwei offene Menüs sähen aus wie ein Fehler: Der Klick auf den Startknopf
|
|
// hält sein Ereignis auf, also erreicht es die Schließer der anderen nicht
|
|
// von selbst.
|
|
closeSystemMenu();
|
|
closeAppMenu();
|
|
|
|
menu.innerHTML = `<div class="sm-head">starten</div>` +
|
|
state.cfg.apps
|
|
.map((a) => `<button data-app="${escapeAttr(a.id)}"><span class="sm-ico">${glyphSvg(a.glyph)}</span>${escapeHtml(a.name)}</button>`)
|
|
.join("") +
|
|
`<hr /><button data-win="programme">Programme</button>
|
|
<button data-win="system">System</button>
|
|
<button data-win="settings">Einstellungen</button>
|
|
<button data-win="about">Über HoneyOS</button>
|
|
<hr /><button data-mode="terminal">↩ Terminal-Modus</button>`;
|
|
|
|
menu.querySelectorAll("[data-app]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
const app = state.cfg.apps.find((a) => a.id === b.dataset.app);
|
|
closeStartMenu();
|
|
if (app) launch(app);
|
|
})
|
|
);
|
|
menu.querySelectorAll("[data-win]").forEach((b) =>
|
|
b.addEventListener("click", () => {
|
|
closeStartMenu();
|
|
({ programme: openProgramsWindow, system: openSystemWindow, settings: openSettings, about: openAbout })[
|
|
b.dataset.win
|
|
]();
|
|
})
|
|
);
|
|
menu.querySelector("[data-mode]").addEventListener("click", () => {
|
|
closeStartMenu();
|
|
setMode("terminal");
|
|
});
|
|
|
|
menu.dataset.open = "1";
|
|
btn.setAttribute("aria-expanded", "true");
|
|
});
|
|
|
|
document.addEventListener("click", closeStartMenu);
|
|
}
|
|
|
|
function closeStartMenu() {
|
|
const menu = $("startmenu");
|
|
if (!menu) return;
|
|
menu.dataset.open = "0";
|
|
$("btnStart").setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
/* ── Start ─────────────────────────────────────────────────────────────── */
|
|
|
|
/* ── Anmeldeschirm ─────────────────────────────────────────────────────────
|
|
* Kein Formular: HoneyOS kennt keine Passwörter. Es gibt genau einen Knopf,
|
|
* und der schickt zum Anbieter. Der Rest ist Kulisse — aber Kulisse, die sagt,
|
|
* was schiefging, wenn der Rückweg gescheitert ist.
|
|
*/
|
|
const LOGIN_ERRORS = {
|
|
anbieter: "Der Anmeldedienst war nicht erreichbar.",
|
|
abgelaufen: "Der Versuch hat zu lange gedauert. Noch einmal.",
|
|
state: "Die Antwort passte nicht zur Anfrage. Noch einmal.",
|
|
kein_code: "Der Anbieter hat keinen Code geschickt.",
|
|
tausch: "Der Code ließ sich nicht einlösen — Client-ID oder Geheimnis prüfen.",
|
|
kein_id_token: "Der Anbieter hat kein ID-Token geschickt.",
|
|
token: "Das ID-Token war ungültig.",
|
|
claims: "Das ID-Token nannte keinen Nutzer.",
|
|
nicht_erlaubt: "Dieses Konto steht nicht auf der erlaubten Liste.",
|
|
};
|
|
|
|
function showLogin(pub) {
|
|
const el = $("login");
|
|
const log = $("loginLog");
|
|
el.hidden = false;
|
|
|
|
const fehler = new URLSearchParams(location.search).get("fehler");
|
|
const lines = [
|
|
`HoneyOS ${state.cfg.version} — ${state.cfg.hostname}`,
|
|
"",
|
|
"prüfe sitzung".padEnd(28, ".") + " keine",
|
|
"anmeldung erforderlich",
|
|
];
|
|
log.textContent = lines.join("\n") + "\n";
|
|
|
|
const note = $("loginNote");
|
|
if (fehler) {
|
|
note.innerHTML = `<b>${escapeHtml(LOGIN_ERRORS[fehler] || "Die Anmeldung ist gescheitert (" + fehler + ").")}</b>`;
|
|
} else {
|
|
note.textContent = "Die Anmeldung läuft über deinen Anbieter. HoneyOS speichert kein Passwort.";
|
|
}
|
|
|
|
const btn = $("btnLogin");
|
|
$("loginLabel").textContent = pub.buttonLabel || "Anmelden";
|
|
btn.addEventListener("click", () => {
|
|
btn.disabled = true;
|
|
$("loginLabel").textContent = "weiterleiten …";
|
|
location.href = "/api/auth/login";
|
|
});
|
|
btn.focus();
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await api("/api/auth/logout", { method: "POST" });
|
|
} catch {
|
|
/* egal — die Sitzung ist so oder so nichts mehr wert */
|
|
}
|
|
try {
|
|
sessionStorage.removeItem(SS_BOOTED);
|
|
} catch {
|
|
/* dann bootet es beim nächsten Mal eben neu */
|
|
}
|
|
location.href = "/";
|
|
}
|
|
|
|
/* ── Start ─────────────────────────────────────────────────────────────── */
|
|
|
|
async function main() {
|
|
// Einstellungen aus dem letzten Besuch gelten sofort, damit die Röhre beim
|
|
// Laden nicht in der falschen Farbe aufflackert.
|
|
loadLocalSettings();
|
|
applySettings();
|
|
|
|
let pub = { oidc: false, buttonLabel: "Anmelden" };
|
|
try {
|
|
pub = await api("/api/public");
|
|
if (pub.version) state.cfg.version = pub.version;
|
|
} catch {
|
|
/* Server nicht erreichbar — der Anmeldeschirm sagt es gleich selbst */
|
|
}
|
|
|
|
try {
|
|
await loadFromServer();
|
|
} catch {
|
|
return showLogin(pub); // nicht angemeldet: hier ist Schluss
|
|
}
|
|
|
|
applySettings(); // jetzt gilt, was der Server gespeichert hat
|
|
|
|
let skipBoot = false;
|
|
try {
|
|
skipBoot = sessionStorage.getItem(SS_BOOTED) === "1";
|
|
} catch {
|
|
/* kein sessionStorage — dann bootet es jedes Mal, auch schön */
|
|
}
|
|
if (!skipBoot) await runBoot();
|
|
|
|
renderShell();
|
|
buildStartMenu();
|
|
buildSystemMenu();
|
|
buildAppMenu();
|
|
probeApps();
|
|
setInterval(tickClock, 15000);
|
|
setInterval(probeApps, 120000);
|
|
|
|
$("btnClose").addEventListener("click", closeModal);
|
|
$("modal").addEventListener("click", (e) => {
|
|
if (e.target === $("modal")) closeModal();
|
|
});
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
|
if (e.key === "Escape") {
|
|
// Bei offenem Startmenü schließt Esc nur das Menü — sonst nähme es
|
|
// einem gleichzeitig das oberste Fenster weg.
|
|
const menu = $("startmenu");
|
|
if (menu && menu.dataset.open === "1") return closeStartMenu();
|
|
const sys = $("sysmenu");
|
|
if (sys && sys.dataset.open === "1") return closeSystemMenu();
|
|
const apps = $("appmenu");
|
|
if (apps && apps.dataset.open === "1") return closeAppMenu();
|
|
return state.settings.mode === "desktop" ? closeTopWindow() : closeModal();
|
|
}
|
|
if (typing) return;
|
|
|
|
if (e.key === "m" || e.key === "M") setMode(state.settings.mode === "desktop" ? "terminal" : "desktop");
|
|
if (e.key === "s" || e.key === "S") openSettings();
|
|
if (e.key === "?") openAbout();
|
|
});
|
|
}
|
|
|
|
/** Esc schließt im Desktop-Modus das oberste Fenster. */
|
|
function closeTopWindow() {
|
|
const entries = Object.entries(state.wins).filter(([, w]) => !w.min);
|
|
if (!entries.length) return;
|
|
entries.sort((a, b) => Number(b[1].el.style.zIndex || 0) - Number(a[1].el.style.zIndex || 0));
|
|
closeWindow(entries[0][0]);
|
|
}
|
|
|
|
main();
|