Files
HoneyOS/web/honeybar/honeybar.js
T
marcandClaude Opus 5 84cc5d619e feat: HoneyOS – Retro-Schreibtisch und Leiste für die eigenen Apps
Statische Oberfläche vor Aether und InkNote: kein Backend, kein Build,
nginx liefert web/ aus. Jede App bleibt ihre eigene PWA auf ihrer eigenen
Domain – HoneyOS ist Startpunkt und Umschalter, kein iframe-Rahmen.

- zwei Betriebsarten aus einem Datenstand: Terminal (Kacheln, Tastatur)
  und Desktop (Symbole, verschiebbare Fenster, Taskleiste)
- honeybar.js als Drop-in für die Apps, rendert im Shadow DOM, damit
  Tailwind und die InkNote-Palette einander nicht in die Quere kommen
- Konfiguration vollständig aus der .env: das Skript in
  /docker-entrypoint.d/ schreibt config.json beim Containerstart
- optionaler _ME-Pfad pro App zeigt an, wer angemeldet ist; ohne CORS
  bleibt die Anzeige leer, ohne dass etwas kaputtgeht

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:45:55 +02:00

291 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* HoneyBar — die HoneyOS-Leiste zum Einhängen in eine fremde App.
*
* <script src="https://os.honeyy.duckdns.org/honeybar/honeybar.js"
* data-app="aether"></script>
*
* Eine Zeile, kein Build, keine Abhängigkeit. Die Leiste rendert in einem
* Shadow DOM — die Stile der Gastgeber-App kommen nicht rein, unsere nicht
* raus. Das ist der ganze Grund für die Konstruktion: Aether ist React +
* Tailwind, InkNote React + eigene Palette, und beide sollen von der Leiste
* nichts merken außer den 32 Pixeln oben.
*
* data-Attribute am <script>:
* data-app (Pflicht) id der App, in der die Leiste steckt
* data-apps URL zu einer config.json; ohne das gilt die Liste unten
* data-home URL des HoneyOS-Desktops
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
* "none" legt die Leiste darüber
*
* Wichtig zu wissen: localStorage ist pro Domain. Was auf os.honeyy… steht,
* sieht diese Leiste auf notes.honeyy… nicht — die Leiste ist ein Schalter,
* kein geteilter Zustand. Gemeinsame Sitzung wäre echtes SSO und ist eine
* eigene Baustelle.
*/
(function () {
"use strict";
if (window.__honeyBar) return; // zweimal eingebunden ist kein Fehler
window.__honeyBar = true;
const script = document.currentScript;
const d = (k, fb) => (script && script.dataset[k]) || fb;
const BAR_H = 32;
const LS_HIDDEN = "honeybar.hidden";
const DEFAULT_APPS = [
{ id: "honeyos", name: "DESKTOP", url: "https://os.honeyy.duckdns.org" },
{ id: "aether", name: "AETHER", url: "https://cloud.honeyy.duckdns.org" },
{ id: "inknote", name: "INKNOTE", url: "https://notes.honeyy.duckdns.org" },
];
const current = d("app", "");
const homeUrl = d("home", "https://os.honeyy.duckdns.org");
const pushMode = d("push", "body");
// Ohne data-apps holt die Leiste die Konfiguration neben ihrem eigenen
// Skript — dieselbe Datei, die der Desktop liest.
const appsUrl = d("apps", script ? new URL("../config.json", script.src).href : "");
let apps = DEFAULT_APPS;
/* ── Aufbau ───────────────────────────────────────────────────────────── */
const host = document.createElement("honey-bar");
host.style.cssText = [
"position:fixed", "top:0", "left:0", "right:0",
"height:" + BAR_H + "px",
"z-index:2147483000",
"display:block",
"color-scheme:dark",
].join(";");
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host, * { box-sizing: border-box; }
.bar {
display: flex; align-items: center; gap: 10px;
height: ${BAR_H}px; padding: 0 8px;
background: #140d07;
border-bottom: 1px solid #6b4718;
color: #ffb641;
font: 12px/1 ui-monospace, "SF Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
text-shadow: 0 0 5px rgba(255,182,65,.4);
user-select: none;
}
.bar::after {
/* Scanlines, damit die Leiste zum Desktop gehört */
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(to bottom, rgba(0,0,0,.13) 0 1px, transparent 1px 3px);
}
a, button {
font: inherit; color: inherit; background: none;
border: 1px solid transparent; cursor: pointer;
text-decoration: none; padding: 3px 9px; white-space: nowrap;
}
.home { letter-spacing: .14em; color: #ffb641; padding-left: 2px; }
.home b { color: #c8842a; font-weight: 400; }
.home:hover { border-color: #6b4718; }
.apps { display: flex; gap: 4px; flex: 1; overflow-x: auto; scrollbar-width: none; }
.apps::-webkit-scrollbar { display: none; }
.app { color: #c8842a; letter-spacing: .08em; }
.app:hover { color: #ffb641; border-color: #6b4718; }
.app[aria-current="page"] {
background: #ffb641; border-color: #ffb641; color: #140d07; text-shadow: none;
}
.right { display: flex; align-items: center; gap: 8px; }
.user { color: #ffb641; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user[hidden] { display: none; }
.clock { color: #c8842a; font-variant-numeric: tabular-nums; }
.menu-btn { border-color: #6b4718; line-height: 1; padding: 3px 7px; }
.menu-btn:hover { background: #2a1c0c; }
.menu {
position: absolute; top: ${BAR_H + 2}px; right: 6px; min-width: 190px;
background: #140d07; border: 1px solid #6b4718; padding: 4px;
display: none; flex-direction: column; gap: 2px;
box-shadow: 0 10px 30px rgba(0,0,0,.6);
}
.menu[data-open="1"] { display: flex; }
.menu a, .menu button { text-align: left; padding: 6px 8px; color: #c8842a; }
.menu a:hover, .menu button:hover { background: #2a1c0c; color: #ffb641; }
.menu hr { border: 0; border-top: 1px solid #2a1c0c; margin: 3px 0; }
.menu .head {
padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
text-transform: uppercase; color: #6b4718;
}
@media (max-width: 560px) { .clock { display: none; } }
</style>
<div class="bar">
<a class="home" id="home" title="HoneyOS">&#9632; HONEY<b>OS</b></a>
<nav class="apps" id="apps"></nav>
<div class="right">
<span class="user" id="user" hidden></span>
<span class="clock" id="clock"></span>
<button class="menu-btn" id="menuBtn" aria-haspopup="true" aria-expanded="false">&#9662;</button>
</div>
</div>
<div class="menu" id="menu" role="menu">
<div class="head">wechseln zu</div>
<div id="menuApps"></div>
<hr />
<button id="hide" role="menuitem">leiste ausblenden</button>
</div>`;
/* ── Verhalten ────────────────────────────────────────────────────────── */
function render() {
const nav = root.getElementById("apps");
const menuApps = root.getElementById("menuApps");
nav.innerHTML = "";
menuApps.innerHTML = "";
apps.forEach((app) => {
if (app.id === "honeyos") return; // steckt schon links im Logo
const a = document.createElement("a");
a.className = "app";
a.href = app.url;
a.textContent = app.name;
if (app.id === current) a.setAttribute("aria-current", "page");
nav.appendChild(a);
const m = document.createElement("a");
m.href = app.url;
m.setAttribute("role", "menuitem");
m.textContent = (app.id === current ? "▸ " : "  ") + app.name;
menuApps.appendChild(m);
});
}
/**
* Zeigt an, wer angemeldet ist. Die Leiste steckt in der App selbst, der
* Aufruf geht also an denselben Ursprung — kein CORS, keine Freigabe nötig.
* Antwortet die App nicht wie erwartet, bleibt das Feld leer und niemand
* merkt etwas: die Anzeige ist eine Zugabe, keine Zusicherung.
*/
async function showUser() {
const me = apps.find((a) => a.id === current);
if (!me || !me.me) return;
try {
const headers = {};
if (me.tokenKey) {
const tok = localStorage.getItem(me.tokenKey);
if (tok) headers.Authorization = "Bearer " + tok;
}
const res = await fetch(url(me, me.me), { credentials: "same-origin", cache: "no-store", headers });
if (!res.ok) return;
const data = await res.json();
const u = (data && (data.user || data.profile)) || data || {};
const name = u.display_name || u.displayName || u.name || u.username || u.preferred_username;
if (!name) return;
const el = root.getElementById("user");
el.textContent = name;
el.hidden = false;
} catch {
/* nicht angemeldet, kein Endpunkt, kein Netz — alles kein Grund zu lärmen */
}
}
function url(app, path) {
if (!path) return app.url;
if (/^https?:\/\//i.test(path)) return path;
return app.url.replace(/\/+$/, "") + (path.startsWith("/") ? path : "/" + path);
}
function clock() {
root.getElementById("clock").textContent = new Date().toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
});
}
function mount() {
document.body.appendChild(host);
// Der Seite Platz verschaffen. `--honeybar-h` steht bereit, falls eine App
// ihre eigene 100vh-Rechnung anpassen muss (siehe README).
document.documentElement.style.setProperty("--honeybar-h", BAR_H + "px");
if (pushMode === "body") {
const cs = getComputedStyle(document.body);
document.body.style.paddingTop = parseFloat(cs.paddingTop || 0) + BAR_H + "px";
}
render();
clock();
setInterval(clock, 20000);
root.getElementById("home").href = homeUrl;
const menu = root.getElementById("menu");
const btn = root.getElementById("menuBtn");
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu.dataset.open === "1";
menu.dataset.open = open ? "0" : "1";
btn.setAttribute("aria-expanded", String(!open));
});
// Klick daneben schließt — das Ereignis muss die Grenze des Shadow DOM
// in beide Richtungen überleben, deshalb beide Wurzeln.
const close = () => {
menu.dataset.open = "0";
btn.setAttribute("aria-expanded", "false");
};
document.addEventListener("click", close);
root.addEventListener("click", (e) => {
if (!e.composedPath().includes(menu) && e.target !== btn) close();
});
root.getElementById("hide").addEventListener("click", () => {
try {
localStorage.setItem(LS_HIDDEN, "1");
} catch {
/* dann eben nur für diesen Aufruf */
}
unmount();
});
}
function unmount() {
host.remove();
if (pushMode === "body") {
const cs = getComputedStyle(document.body);
document.body.style.paddingTop = Math.max(0, parseFloat(cs.paddingTop || 0) - BAR_H) + "px";
}
document.documentElement.style.removeProperty("--honeybar-h");
}
async function start() {
let hidden = false;
try {
hidden = localStorage.getItem(LS_HIDDEN) === "1";
} catch {
/* kein Speicher, Leiste bleibt sichtbar */
}
if (hidden) return;
if (appsUrl) {
try {
const res = await fetch(appsUrl, { cache: "no-store" });
if (res.ok) {
const cfg = await res.json();
if (Array.isArray(cfg.apps) && cfg.apps.length) {
apps = [{ id: "honeyos", name: "DESKTOP", url: homeUrl }].concat(cfg.apps);
}
}
} catch {
/* Liste nicht erreichbar → eingebaute Liste, Leiste funktioniert trotzdem */
}
}
if (document.body) mount();
else document.addEventListener("DOMContentLoaded", mount, { once: true });
showUser();
}
start();
})();