`;
}
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;
}
/* ── 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.
*/
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 `
${glyphSvg(w.glyph)}
${Math.round(w.grad)}°${w.text}${w.ort}
`;
}
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)}${Math.round(w.grad)}°`;
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
}
}
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]) => `
${k}
${escapeHtml(v)}
`).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 = `${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 ? `[${app.key}]` : ""}
${glyphSvg(app.glyph)}${escapeHtml(app.name)}${escapeHtml(app.subtitle || "")}prüfe …`;
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
? "+ app hinzufügen"
: "Noch keine App. Hier die erste anlegen.";
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 = `${glyphSvg(item.glyph)}${escapeHtml(item.name)}`;
// 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 = `
${escapeHtml(title)}
${body}
`;
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: `
Wetter
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
Programme
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
`,
(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) => ``)
.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) => `
${glyphSvg(a.glyph)}${escapeHtml(a.name || a.id || "neue app")}${escapeHtml(a.url || "")}
klein, ohne Leerzeichen — muss zu data-app der HoneyBar passen
HoneyOS ${escapeHtml(state.cfg.version)} — 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.
Zwei Betriebsarten, ein Datenstand: terminal zeigt Kacheln,
desktop Symbole und Fenster. Die HoneyBar oben ist in beiden dieselbe —
und dieselbe steckt per honeybar/honeybar.js auch in Aether und InkNote.