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>
This commit is contained in:
commit
84cc5d619e
17 files changed
+3126
No files matched your search
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HoneyBar — Vorschau in einer fremden App</title>
|
||||
<style>
|
||||
/* Bewusst NICHT im HoneyOS-Stil: hier wird geprüft, dass die Leiste
|
||||
eine ganz andere App nicht anfasst und von ihr nicht angefasst wird. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font: 15px/1.6 system-ui, sans-serif;
|
||||
}
|
||||
header.fake {
|
||||
padding: 14px 20px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
font-weight: 600;
|
||||
}
|
||||
main { padding: 24px 20px; max-width: 620px; }
|
||||
code { background: #161b22; padding: 2px 5px; border-radius: 4px; font-size: 13px; }
|
||||
/* So rechnet eine App mit Vollhöhe die Leiste heraus: */
|
||||
.full { height: calc(100vh - var(--honeybar-h, 0px)); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="fake">Irgendeine App (dunkles Theme, eigene Schrift)</header>
|
||||
<main>
|
||||
<p>Die Leiste oben kommt aus <code>honeybar.js</code> und gehört nicht zu dieser Seite.</p>
|
||||
<p>
|
||||
Sie liegt in einem Shadow DOM: nichts von hier färbt auf sie ab, nichts von ihr auf
|
||||
diese Seite. <code>data-app="inknote"</code> markiert, wo man gerade ist.
|
||||
</p>
|
||||
<p>Eine App, die mit <code>100vh</code> rechnet, nimmt <code>--honeybar-h</code> ab.</p>
|
||||
</main>
|
||||
|
||||
<script src="./honeybar.js" data-app="inknote" data-home="../index.html"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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">■ 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">▾</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();
|
||||
})();
|
||||
Reference in new issue
Block a user