refactor: aufgeraeumt — Pakete hinten, Widgets vorne
Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher lagen fuenf Dateien in einem Topf, und jede kannte jede. Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server, Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je Datei, os.js nur noch die Ansichten. Stylesheet nach css/. Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden Seiten mit einer Zeile eingebunden, da darf nichts nachladen. Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert. Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d08b0a229c
commit
52deffa30e
20 files changed
+780
-624
No files matched your search
+120
@@ -0,0 +1,120 @@
|
||||
/* Der gemeinsame Unterbau: Zustand, Server, Einstellungen, Kleinkram.
|
||||
*
|
||||
* Alles, was mehr als eine Datei braucht, steht hier — und nur das. Die
|
||||
* Ansichten (os.js) und die Widgets holen es sich von hier, statt sich
|
||||
* gegenseitig zu kennen.
|
||||
*/
|
||||
export const LS_SETTINGS = "honeyos.settings";
|
||||
export const SS_BOOTED = "honeyos.booted";
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
mode: "terminal", // terminal | desktop
|
||||
phosphor: "amber", // amber | green | ice
|
||||
scanlines: "on",
|
||||
flicker: "on",
|
||||
glow: "on",
|
||||
openIn: "tab", // tab | same
|
||||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
|
||||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
||||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
||||
weatherBar: "off",
|
||||
weatherDesk: "off",
|
||||
weatherCity: "",
|
||||
weatherLat: "",
|
||||
weatherLon: "",
|
||||
};
|
||||
|
||||
export const state = {
|
||||
cfg: { user: "", hostname: "honeyos", version: "…", apps: [] },
|
||||
me: null, // {sub, name, picture} sobald angemeldet
|
||||
weather: null, // zuletzt geholtes Wetter, oder null
|
||||
oidc: false,
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
probes: {}, // appId -> "up" | "down" | "unknown"
|
||||
wins: {}, // id -> {el, task, min}
|
||||
zTop: 10,
|
||||
};
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
export const isTouch = () => window.matchMedia("(pointer: coarse)").matches;
|
||||
|
||||
/* ── Server ────────────────────────────────────────────────────────────────
|
||||
* Ein winziger Aufrufhelfer. Schreibende Aufrufe brauchen die CSRF-Kopfzeile:
|
||||
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
|
||||
* die Seite wirklich geöffnet hat.
|
||||
*/
|
||||
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
|
||||
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
|
||||
export const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
|
||||
|
||||
export async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||
if (opts.method && opts.method !== "GET") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
const tok = cookie("honeyos_csrf");
|
||||
if (tok) headers["X-CSRF-Token"] = tok;
|
||||
}
|
||||
const res = await fetch(path, { credentials: "same-origin", cache: "no-store", ...opts, headers });
|
||||
if (res.status === 204) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) throw new Error((body && body.error) || `${res.status} ${res.statusText}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
export function cookie(name) {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim().split("="))
|
||||
.find(([k]) => k === name)?.[1] || "";
|
||||
}
|
||||
|
||||
export function loadLocalSettings() {
|
||||
state.settings = { ...DEFAULT_SETTINGS, ...(readJson(LS_SETTINGS) || {}) };
|
||||
}
|
||||
|
||||
export function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let syncTimer = null;
|
||||
|
||||
export function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(state.settings));
|
||||
} catch {
|
||||
/* Privatmodus: dann gilt es nur für diese Sitzung — der Server merkt es trotzdem */
|
||||
}
|
||||
applySettings();
|
||||
|
||||
// Zum Server gebündelt: wer dreimal hintereinander die Röhre wechselt,
|
||||
// soll nicht dreimal schreiben lassen.
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => {
|
||||
api("/api/settings", { method: "PUT", body: JSON.stringify(state.settings) }).catch((e) =>
|
||||
console.warn("Einstellungen nicht gespeichert:", e.message)
|
||||
);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||
}
|
||||
export function escapeAttr(s) {
|
||||
return escapeHtml(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
/* Die Einstellungen schlagen als Datenattribute auf die Seite durch; das CSS
|
||||
hängt daran. Steht hier, weil saveSettings() es sofort anwenden muss. */
|
||||
export function applySettings() {
|
||||
const r = document.documentElement;
|
||||
r.dataset.phosphor = state.settings.phosphor;
|
||||
r.dataset.scanlines = state.settings.scanlines;
|
||||
r.dataset.flicker = state.settings.flicker;
|
||||
r.dataset.glow = state.settings.glow;
|
||||
r.dataset.mode = state.settings.mode;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* Die Pixelsymbole von HoneyOS.
|
||||
*
|
||||
* Eigene Datei, weil sie Daten sind und nicht Verhalten: zwölf mal zwölf
|
||||
* Zeichen je Symbol, mehr passiert hier nicht. Dieselben Raster stehen noch
|
||||
* einmal in honeybar/honeybar.js — die soll eine einzelne Datei bleiben, die
|
||||
* eine fremde App mit einer Zeile einbindet (siehe IDEAS 1.3).
|
||||
*/
|
||||
/* ── Pixel-Glyphen ─────────────────────────────────────────────────────────
|
||||
* 12×12-Raster als Zeichenketten. Ein `#` ist ein Pixel. So bleiben die Icons
|
||||
* im Retro-Raster statt aus einer Icon-Bibliothek zu kommen.
|
||||
*/
|
||||
export const GLYPHS = {
|
||||
// Wettersymbole. Dieselben Namen wie in weather.go (wmo) und in der HoneyBar —
|
||||
// solange 1.3 (geteilte Symbolquelle) nicht gebaut ist, stehen sie zweimal da.
|
||||
sonne: [
|
||||
"............",
|
||||
".....##.....",
|
||||
"..#..##..#..",
|
||||
"...#....#...",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
"..##....##..",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#..##..#..",
|
||||
".....##.....",
|
||||
"............",
|
||||
],
|
||||
heiter: [
|
||||
"............",
|
||||
"...##.......",
|
||||
"..####..#...",
|
||||
"...##..###..",
|
||||
"......#####.",
|
||||
"...####...#.",
|
||||
"..#....##.#.",
|
||||
".##........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
wolke: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
nebel: [
|
||||
"............",
|
||||
"....####....",
|
||||
"..##....##..",
|
||||
".##.......#.",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
".#########..",
|
||||
"............",
|
||||
"..#########.",
|
||||
"............",
|
||||
".#########..",
|
||||
],
|
||||
regen: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
"..#..#..#...",
|
||||
".#..#..#....",
|
||||
],
|
||||
schnee: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"..#...#...#.",
|
||||
".#.#.#.#.#..",
|
||||
"..#...#...#.",
|
||||
"............",
|
||||
],
|
||||
gewitter: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
".##########.",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
"....####....",
|
||||
"......##....",
|
||||
".....##.....",
|
||||
],
|
||||
|
||||
cloud: [
|
||||
"............",
|
||||
"....####....",
|
||||
"...#....#...",
|
||||
"..#......##.",
|
||||
".##........#",
|
||||
"##.........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
".##########.",
|
||||
"............",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
note: [
|
||||
"..########..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.####.#..",
|
||||
"..#......#..",
|
||||
"..#.##...#..",
|
||||
"..#......#..",
|
||||
"..########..",
|
||||
"............",
|
||||
],
|
||||
box: [
|
||||
"............",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".#...##...#.",
|
||||
".#..####..#.",
|
||||
".#..####..#.",
|
||||
".#...##...#.",
|
||||
".#........#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"............",
|
||||
],
|
||||
chip: [
|
||||
"............",
|
||||
"..#.#.#.#...",
|
||||
".##########.",
|
||||
".#........#.",
|
||||
"##.######.##",
|
||||
".#.#....#.#.",
|
||||
"##.#....#.##",
|
||||
".#.######.#.",
|
||||
".#........#.",
|
||||
".##########.",
|
||||
"..#.#.#.#...",
|
||||
"............",
|
||||
],
|
||||
gear: [
|
||||
"....####....",
|
||||
"..#.####.#..",
|
||||
"..########..",
|
||||
".####..####.",
|
||||
"###......###",
|
||||
"##........##",
|
||||
"##........##",
|
||||
"###......###",
|
||||
".####..####.",
|
||||
"..########..",
|
||||
"..#.####.#..",
|
||||
"....####....",
|
||||
],
|
||||
globe: [
|
||||
"............",
|
||||
"...######...",
|
||||
"..#..#...#..",
|
||||
".#...#....#.",
|
||||
"############",
|
||||
"#....#.....#",
|
||||
"#....#.....#",
|
||||
"############",
|
||||
".#...#....#.",
|
||||
"..#..#...#..",
|
||||
"...######...",
|
||||
"............",
|
||||
],
|
||||
disk: [
|
||||
"............",
|
||||
"############",
|
||||
"#..######..#",
|
||||
"#..#....#..#",
|
||||
"#..#....#..#",
|
||||
"#..######..#",
|
||||
"#..........#",
|
||||
"#.########.#",
|
||||
"#.#......#.#",
|
||||
"#.#......#.#",
|
||||
"############",
|
||||
"............",
|
||||
],
|
||||
folder: [
|
||||
"............",
|
||||
".####.......",
|
||||
"#....#......",
|
||||
"#.....######",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"#..........#",
|
||||
"############",
|
||||
"............",
|
||||
"............",
|
||||
],
|
||||
};
|
||||
|
||||
export function glyphSvg(name) {
|
||||
const grid = GLYPHS[name] || GLYPHS.box;
|
||||
const rects = [];
|
||||
grid.forEach((row, y) => {
|
||||
// Waagerechte Läufe zusammenfassen: weniger Rechtecke, gleiches Bild.
|
||||
let run = 0;
|
||||
for (let x = 0; x <= row.length; x++) {
|
||||
if (row[x] === "#") {
|
||||
run++;
|
||||
} else if (run) {
|
||||
rects.push(`<rect x="${x - run}" y="${y}" width="${run}" height="1"/>`);
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
return `<svg viewBox="0 0 12 12" fill="currentColor" shape-rendering="crispEdges" aria-hidden="true">${rects.join("")}</svg>`;
|
||||
}
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
import {
|
||||
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch,
|
||||
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
|
||||
SS_BOOTED, state,
|
||||
} from "./core.js";
|
||||
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
||||
import { renderUser } from "./widgets/user.js";
|
||||
import { loadWeather, renderWeather } from "./widgets/weather.js";
|
||||
|
||||
/*
|
||||
* 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");
|
||||
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;
|
||||
$("btnLogout").hidden = !state.oidc;
|
||||
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.
|
||||
loadWeather();
|
||||
setInterval(loadWeather, 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
function renderMenubarApps() {
|
||||
const nav = $("mbApps");
|
||||
nav.innerHTML = "";
|
||||
state.cfg.apps.forEach((app) => {
|
||||
const b = document.createElement("button");
|
||||
b.className = "mb-app";
|
||||
b.textContent = 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();
|
||||
}
|
||||
renderStats();
|
||||
renderUser();
|
||||
renderWeather();
|
||||
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. */
|
||||
function renderStats() {
|
||||
const up = Object.values(state.probes).filter((s) => s === "up").length;
|
||||
const rows = [
|
||||
["apps", `${state.cfg.apps.length}`],
|
||||
["erreichbar", `${up}/${state.cfg.apps.length}`],
|
||||
["modus", state.settings.mode],
|
||||
["röhre", state.settings.phosphor],
|
||||
["version", `v${state.cfg.version}`],
|
||||
];
|
||||
const html = rows.map(([k, v]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`).join("");
|
||||
document.querySelectorAll(".statlist").forEach((dl) => (dl.innerHTML = html));
|
||||
|
||||
}
|
||||
|
||||
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 = "";
|
||||
|
||||
state.cfg.apps.forEach((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 = `
|
||||
${app.key ? `<span class="tile-key">[${app.key}]</span>` : ""}
|
||||
<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);
|
||||
});
|
||||
wrap.appendChild(a);
|
||||
});
|
||||
|
||||
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,
|
||||
body: `<p class="greet"></p><dl class="statlist"></dl>`,
|
||||
onMount: renderStats,
|
||||
});
|
||||
}
|
||||
|
||||
function openProgramsWindow() {
|
||||
const rows = state.cfg.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("");
|
||||
|
||||
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;
|
||||
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>`;
|
||||
|
||||
openPanel(
|
||||
"settings",
|
||||
"einstellungen",
|
||||
`
|
||||
<div class="field">
|
||||
<span class="label">Betriebsart</span>
|
||||
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Röhre</span>
|
||||
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Bildröhren-Effekte</span>
|
||||
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Apps öffnen</span>
|
||||
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Wetter</span>
|
||||
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
|
||||
<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="ae-hint" id="wxCurrent" style="margin-left:0"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Programme</span>
|
||||
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
||||
</div>
|
||||
|
||||
<p class="login-note" style="margin-top:4px">
|
||||
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
||||
</p>`,
|
||||
(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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
420
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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.
|
||||
*/
|
||||
function openApps() {
|
||||
let draft = state.cfg.apps.map((a) => ({ ...a }));
|
||||
|
||||
const GLYPHS_LIST = ["cloud", "note", "box", "chip", "folder", "gear", "globe", "disk"];
|
||||
|
||||
const rowHtml = (a, i) => `
|
||||
<details class="approw" ${a.__neu ? "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>
|
||||
</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>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>
|
||||
<label class="ae-field"><span>Taste</span>
|
||||
<input data-i="${i}" data-f="key" value="${escapeAttr(a.key || "")}" maxlength="1" placeholder="1" /></label>
|
||||
<div class="ae-actions">
|
||||
<button class="btn btn-danger" data-del="${i}">löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>`;
|
||||
|
||||
const render = (root) => {
|
||||
const list = root.querySelector("#appList");
|
||||
list.innerHTML = draft.length
|
||||
? draft.map(rowHtml).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;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll("[data-del]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
draft.splice(Number(b.dataset.del), 1);
|
||||
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", () => {
|
||||
draft.push({ id: "", name: "", url: "", glyph: "box", key: String(draft.length + 1), __neu: 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(({ __neu, ...rest }) => rest);
|
||||
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps;
|
||||
draft = state.cfg.apps.map((a) => ({ ...a }));
|
||||
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">
|
||||
${state.cfg.apps.map((a) => `<dt>${a.key || "·"}</dt><dd>${escapeHtml(a.name)} starten</dd>`).join("")}
|
||||
<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 ───────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
/* ── 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();
|
||||
|
||||
menu.innerHTML = `<div class="sm-head">starten</div>` +
|
||||
state.cfg.apps.map((a) => `<button data-app="${a.id}">${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>
|
||||
<button data-logout="1">⏻ Abmelden</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.querySelector("[data-logout]").addEventListener("click", logout);
|
||||
|
||||
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();
|
||||
probeApps();
|
||||
setInterval(tickClock, 15000);
|
||||
setInterval(probeApps, 120000);
|
||||
|
||||
$("btnSettings").addEventListener("click", openSettings);
|
||||
$("btnAbout").addEventListener("click", openAbout);
|
||||
$("btnLogout").addEventListener("click", logout);
|
||||
$("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();
|
||||
return state.settings.mode === "desktop" ? closeTopWindow() : closeModal();
|
||||
}
|
||||
if (typing) return;
|
||||
|
||||
const app = state.cfg.apps.find((a) => a.key === e.key);
|
||||
if (app) return launch(app);
|
||||
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();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { $, escapeAttr, state } from "../core.js";
|
||||
import { glyphSvg } from "../glyphs.js";
|
||||
|
||||
function greeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 5 || h >= 23) return "Noch wach?";
|
||||
if (h < 11) return "Guten Morgen.";
|
||||
if (h < 18) return "Willkommen zurück.";
|
||||
return "Guten Abend.";
|
||||
}
|
||||
|
||||
/* ── Wer angemeldet ist ────────────────────────────────────────────────────
|
||||
*
|
||||
* Stand früher rechts in der Menüleiste. Dort war es das einzige Stück, das
|
||||
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
|
||||
* merkt man beim Wechsel, dass es zwei verschiedene Leisten sind. Auf dem
|
||||
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
|
||||
*/
|
||||
function userHtml() {
|
||||
const bild = state.me && state.me.picture
|
||||
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
||||
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
||||
return `
|
||||
<div class="uw">
|
||||
${bild}
|
||||
<div class="uw-text">
|
||||
<b class="uw-name">${state.cfg.user}</b>
|
||||
<span class="uw-gruss">${greeting()}</span>
|
||||
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUser() {
|
||||
const tile = $("userTile");
|
||||
const desk = $("userDesk");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
const html = userHtml();
|
||||
|
||||
tile.hidden = desktopMode;
|
||||
desk.hidden = !desktopMode;
|
||||
tile.querySelector(".user-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { $, api, state } from "../core.js";
|
||||
import { glyphSvg } from "../glyphs.js";
|
||||
|
||||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Der Server holt es und legt es zehn Minuten beiseite; hier wird nur gezeigt,
|
||||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
||||
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||||
*/
|
||||
export async function loadWeather() {
|
||||
if (!state.settings.weatherCity) {
|
||||
state.weather = null;
|
||||
renderWeather();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.weather = await api("/api/weather");
|
||||
} catch {
|
||||
state.weather = null;
|
||||
}
|
||||
renderWeather();
|
||||
}
|
||||
|
||||
function weatherHtml(w) {
|
||||
return `
|
||||
<div class="wx">
|
||||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||||
<div class="wx-text">
|
||||
<b class="wx-temp">${Math.round(w.grad)}°</b>
|
||||
<span class="wx-desc">${w.text}</span>
|
||||
<span class="wx-city">${w.ort}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderWeather() {
|
||||
const w = state.weather;
|
||||
const tile = $("wxTile");
|
||||
const desk = $("wxDesk");
|
||||
const bar = $("mbWx");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
|
||||
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
|
||||
const alsWidget = state.settings.weatherDesk === "on" && w;
|
||||
tile.hidden = !alsWidget || desktopMode;
|
||||
desk.hidden = !alsWidget || !desktopMode;
|
||||
if (alsWidget) {
|
||||
const html = weatherHtml(w);
|
||||
tile.querySelector(".wx-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
|
||||
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
|
||||
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
|
||||
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||||
const inLeiste = state.settings.weatherBar === "on" && w;
|
||||
bar.hidden = !inLeiste;
|
||||
if (inLeiste) {
|
||||
bar.innerHTML = `${glyphSvg(w.glyph)}<span class="mb-wx-temp">${Math.round(w.grad)}°</span>`;
|
||||
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user