Compare commits
15
Commits
0e1c986f90
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5b7f8e63 | ||
|
|
501c3b8930 | ||
|
|
3b47003492 | ||
|
|
8ca16aec84 | ||
|
|
ff413864a9 | ||
|
|
3f8b98b869 | ||
|
|
aec042a8c9 | ||
|
|
75df4e0833 | ||
|
|
e2988f7ee9 | ||
|
|
3807ddca24 | ||
|
|
c0abfbb491 | ||
|
|
b7cc06b7c1 | ||
|
|
981a2331df | ||
|
|
b46b699576 | ||
|
|
c070f301bb |
No files matched your search
@@ -43,8 +43,11 @@ Eine Annahme von damals hat sich nicht gehalten: „ohne sie zu verlassen"
|
|||||||
schreibt, und die HoneyBar darf in einer fremden App nicht schreiben — die
|
schreibt, und die HoneyBar darf in einer fremden App nicht schreiben — die
|
||||||
Sitzung kommt über die Site-Grenze nicht mit, und selbst unter einer Domain
|
Sitzung kommt über die Site-Grenze nicht mit, und selbst unter einer Domain
|
||||||
liegt das CSRF-Prüfwort in einem Cookie, das nur HoneyOS' eigene Seiten lesen.
|
liegt das CSRF-Prüfwort in einem Cookie, das nur HoneyOS' eigene Seiten lesen.
|
||||||
Also reicht die HoneyBar die Frage als `?einstellungen=1` an den Schreibtisch
|
Die HoneyBar hat die Frage deshalb als `?einstellungen=1` an den Schreibtisch
|
||||||
weiter. Ein Klick, ein Seitenwechsel — dort, wo man angemeldet ist.
|
weitergereicht. **Auch das ist seit dem 01.09.2026 weg** (Marcs Einwand): Wenn
|
||||||
|
der Punkt ohnehin nur weiterleitet, ist er neben „Schreibtisch" keine zweite
|
||||||
|
Möglichkeit, sondern dieselbe mit einem Zwischenschritt weniger. Das Bar-Menü
|
||||||
|
trägt jetzt Schreibtisch und Einklappen, sonst nichts.
|
||||||
|
|
||||||
**Abmelden ist ganz aus den Leisten verschwunden** und steht unten in den
|
**Abmelden ist ganz aus den Leisten verschwunden** und steht unten in den
|
||||||
Einstellungen (Marcs Entscheidung, 29.08.2026). Aus fremden Apps war es ohnehin
|
Einstellungen (Marcs Entscheidung, 29.08.2026). Aus fremden Apps war es ohnehin
|
||||||
@@ -92,7 +95,7 @@ Kacheln und beantwortet genau die Frage, die ein generisches Symbol offenlässt.
|
|||||||
Hochladen (siehe „Eigene App-Logos" in Teil 2). Sie hängen an derselben Ablage
|
Hochladen (siehe „Eigene App-Logos" in Teil 2). Sie hängen an derselben Ablage
|
||||||
wie das Bild-Widget und gehören deshalb dorthin, nicht mehr ins Fundament.
|
wie das Bild-Widget und gehören deshalb dorthin, nicht mehr ins Fundament.
|
||||||
|
|
||||||
### 1.3b Reihenfolge der Apps — geplant (29.08.2026)
|
### 1.3b Reihenfolge der Apps — ✅ gebaut (29.08.2026)
|
||||||
|
|
||||||
Heute stehen die Apps so, wie sie eingetragen wurden. Ab vier, fünf Stück ist
|
Heute stehen die Apps so, wie sie eingetragen wurden. Ab vier, fünf Stück ist
|
||||||
das eine Frage — in der HoneyBar zuerst, weil dort der Platz am knappsten ist
|
das eine Frage — in der HoneyBar zuerst, weil dort der Platz am knappsten ist
|
||||||
@@ -127,7 +130,7 @@ Sortieren dieselbe Frage aufgeworfen: mitwandern oder stehenbleiben? Beides
|
|||||||
falsch. Neue Apps hängen sich jetzt einfach hinten an, die Reihenfolge macht man
|
falsch. Neue Apps hängen sich jetzt einfach hinten an, die Reihenfolge macht man
|
||||||
mit den Pfeilen.
|
mit den Pfeilen.
|
||||||
|
|
||||||
Beim Bauen zu beachten:
|
Beim Bauen aufgetreten:
|
||||||
|
|
||||||
- Der Klick auf einen Pfeil sitzt in einem `<summary>` — ohne `preventDefault`
|
- Der Klick auf einen Pfeil sitzt in einem `<summary>` — ohne `preventDefault`
|
||||||
klappt jeder Sortierklick die Zeile auf.
|
klappt jeder Sortierklick die Zeile auf.
|
||||||
@@ -165,51 +168,36 @@ ist am 29.08.2026 weggefallen: Aether und InkNote liegen längst unter
|
|||||||
erst nach der Sitzung. **Die HoneyBar weiß dort jetzt, wer schaut.** Was bleibt,
|
erst nach der Sitzung. **Die HoneyBar weiß dort jetzt, wer schaut.** Was bleibt,
|
||||||
ist die Frage, ob es sich bei einem Nutzer lohnt.
|
ist die Frage, ob es sich bei einem Nutzer lohnt.
|
||||||
|
|
||||||
### 1.4 HoneyOS selbst als PWA
|
### 1.4 Widget-Rahmen — ✅ gebaut (30.08.2026)
|
||||||
|
|
||||||
Heute ist HoneyOS eine Website: Es hat `theme-color`, aber kein Manifest, kein
|
Ein Widget ist jetzt eine **Beschreibung**, kein Stück HTML im Dokument:
|
||||||
Symbol und keinen Service Worker — installierbar ist es damit nicht. Am Handy
|
|
||||||
landet man also im Browser mit Adressleiste, während Aether und InkNote als
|
|
||||||
eigene Apps danebenliegen. Das ist genau der Bruch, den das Ganze eigentlich
|
|
||||||
verhindern soll: Der Schreibtisch sollte sich am wenigsten nach Website
|
|
||||||
anfühlen.
|
|
||||||
|
|
||||||
Was dazugehört, klein bis groß:
|
```js
|
||||||
|
widgetAnmelden({
|
||||||
|
id, titel,
|
||||||
|
wo: ["terminal", "desktop"], // wo es erscheinen darf
|
||||||
|
sichtbar: () => …, // Schalter aus → gar nicht erst da
|
||||||
|
laden: async () => false|void, // false = es gibt nichts zu zeigen
|
||||||
|
leer: "verbergen" | "Text", // was dann passiert
|
||||||
|
inhalt: () => "<html>",
|
||||||
|
takt: 600000, // Nachladen, optional
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
1. **Manifest** — Name, Startadresse, `display: standalone`, Hintergrund- und
|
Der Rahmen (`web/js/widgets/rahmen.js`) macht daraus im Terminal-Modus ein
|
||||||
Themenfarbe. Die Farben sollten der eingestellten Röhre folgen; sie liegen
|
Fenster in der Widgetspalte und im Desktop-Modus ein Schild, kennt die vier
|
||||||
im Server, das Manifest kann also erzeugt statt fest hinterlegt werden
|
Zustände (lädt · fertig · nichts da · Fehler) und hält den Nachladetakt. Ein
|
||||||
(Aether macht das beim Symbol schon so).
|
Widget, das seinen Server nicht erreicht, sagt das **in seinem eigenen Rahmen**
|
||||||
2. **Symbol** — ein Pixelglyph in 192 und 512 px, im Amber-Look. Passt zum
|
und reißt weder den Schreibtisch noch die anderen Widgets mit.
|
||||||
Rest und ist ohne fremde Werkzeuge zu machen.
|
|
||||||
3. **Service Worker** — nötig für die Installierbarkeit. Hier ist zu
|
|
||||||
entscheiden, was er überhaupt soll (siehe unten).
|
|
||||||
|
|
||||||
**Die eigentliche Frage ist der Offline-Fall.** HoneyOS zeigt fremde Apps und
|
Umgestellt sind Benutzer, Wetter und der Systemzustand. Aus der `index.html`
|
||||||
später deren Daten — ohne Netz gibt es davon nichts. Ein Schreibtisch, der
|
sind damit vier fest verdrahtete Kästen verschwunden, und aus dem Raster die
|
||||||
offline mit leeren Kacheln dasteht, ist schlimmer als ein ehrlicher Hinweis.
|
Einzelplätze — vorher hätte das dritte Widget beides wieder angefasst.
|
||||||
Sinnvoll wäre: Oberfläche und App-Liste im Cache, damit man die Symbole sieht
|
|
||||||
und antippen kann, und ein deutlicher Vermerk „ohne Verbindung". Widgets
|
|
||||||
bleiben leer, statt Altes von gestern als aktuell auszugeben.
|
|
||||||
|
|
||||||
**Aus dem Aether-Debakel gelernt und hier von Anfang an zu beachten:** Ein
|
**Was dabei offen gelassen wurde:** die dritte Form. Widgets sollen später auch
|
||||||
Eintrag im Cache ist eine ganze Antwort **samt Kopfzeilen**. Wird die Startseite
|
als Aufklappfeld in der HoneyBar leben können, deshalb erzeugt `inhalt()` nur
|
||||||
vorab abgelegt, friert damit auch ihre CSP ein — und eine Änderung an der
|
den Kern; die Verpackung entsteht im Rahmen. Und die Reihenfolge ist eine
|
||||||
Konfiguration erreicht den Browser nie wieder. Also Navigationen Netz zuerst,
|
Liste — genau das, was „Widgets verschieben" braucht.
|
||||||
Cache nur als Rückfall.
|
|
||||||
|
|
||||||
**Reihenfolge:** Erst umziehen (siehe unten), dann installierbar machen. Ein
|
|
||||||
Ursprungswechsel macht aus einer installierten PWA eine fremde App — wer
|
|
||||||
vorher installiert, installiert zweimal.
|
|
||||||
|
|
||||||
### 1.5 Widget-Rahmen
|
|
||||||
|
|
||||||
Ein Fenstertyp, der einen Inhalt holt, ihn anzeigt und sich benimmt, wenn es
|
|
||||||
schiefgeht: lädt gerade, nichts da, nicht angemeldet, Fehler. Einmal richtig
|
|
||||||
gebaut, kostet jedes weitere Widget danach fast nichts.
|
|
||||||
|
|
||||||
Muss in **beiden Betriebsarten** funktionieren — im Desktop als Fenster, im
|
|
||||||
Terminal als Kachel.
|
|
||||||
|
|
||||||
## 2 · Erste Widgets
|
## 2 · Erste Widgets
|
||||||
|
|
||||||
@@ -221,8 +209,10 @@ nicht geplant"). Was bleibt, ist alles, was für sich allein steht: Wetter
|
|||||||
|
|
||||||
### Wetter in der HoneyBar, neben der Uhr — ✅ gebaut (25.08.2026)
|
### Wetter in der HoneyBar, neben der Uhr — ✅ gebaut (25.08.2026)
|
||||||
|
|
||||||
**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem
|
**Steht.** Zwei Orte, unabhängig voneinander: auf dem Schreibtisch, in der
|
||||||
Schreibtisch, in der HoneyBar, beides oder keins. Die Stadt wird einmal gesucht
|
HoneyBar, beides oder keins. Seit dem 31.08.2026 an zwei Stellen — auf dem
|
||||||
|
Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in der
|
||||||
|
Widget-Liste, für die Leiste gibt es den eigenen Schalter. Die Stadt wird einmal gesucht
|
||||||
und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es
|
und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es
|
||||||
zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt.
|
zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt.
|
||||||
|
|
||||||
@@ -298,23 +288,28 @@ Zwei Auswege:
|
|||||||
→ Ersteres ist das Richtige, sobald die HoneyBar mehr kann als heute (Widgets,
|
→ Ersteres ist das Richtige, sobald die HoneyBar mehr kann als heute (Widgets,
|
||||||
Systemmenü). Vorher lohnt der Umbau nicht.
|
Systemmenü). Vorher lohnt der Umbau nicht.
|
||||||
|
|
||||||
### Die HoneyBar ohne `unsafe-inline`
|
### Die HoneyBar ohne `unsafe-inline` — ✅ gebaut (11.09.2026)
|
||||||
|
|
||||||
Fremde Apps bekommen die HoneyBar über den Reverse Proxy (siehe README). Dabei
|
Fremde Apps mussten für die HoneyBar `style-src 'unsafe-inline'` erlauben, weil
|
||||||
muss deren CSP heute `style-src 'unsafe-inline'` erlauben, weil die HoneyBar ihre
|
sie ihre Stile im Shadow DOM als `<style>` mitbrachte — ohne das erschien sie
|
||||||
Stile im Shadow DOM mitbringt — ohne das erscheint sie schwarz auf schwarz.
|
schwarz auf schwarz (gemessen am 28.08.2026).
|
||||||
Gemessen am 28.08.2026 an einer nachgebauten fremden App.
|
|
||||||
|
|
||||||
**Auflösbar:** Die Stile als eigene Datei neben `honeybar.js` ausliefern und im
|
**Anders gelöst als hier geplant.** Der Plan war, die Stile als eigene Datei
|
||||||
Shadow DOM per `<link>` einbinden. Dann genügt `style-src 'self' https://honeyos…`,
|
auszuliefern und per `<link>` einzubinden. Das hätte `'unsafe-inline'` gegen
|
||||||
und die CSP der fremden App bleibt streng. Kostet eine zweite Anfrage, die
|
`style-src … https://honeyos…` getauscht — eine CSP-Änderung gegen eine andere,
|
||||||
zwischengespeichert wird.
|
plus eine zweite Anfrage. Stattdessen hängt die HoneyBar ihre Stile jetzt als
|
||||||
|
konstruiertes Stylesheet (`adoptedStyleSheets`) ein: Die CSP greift nur auf
|
||||||
|
Markup, nicht auf die CSSOM. Damit braucht die Gastgeber-App **gar keine
|
||||||
|
`style-src`-Erlaubnis** mehr, und es bleibt bei einer Datei ohne zweite Anfrage.
|
||||||
|
|
||||||
Zu prüfen ist dabei, was sonst noch inline gesetzt wird: die Lage der HoneyBar
|
Der Strich zum Wiederausklappen hat seinen eigenen Shadow-Root und wäre sonst
|
||||||
selbst und der Innenabstand, den sie der Seite verpasst. Über die CSSOM gesetzte
|
das einzige Stück ohne Farbe geblieben — er geht denselben Weg.
|
||||||
Stile (`el.style.x = …`) fallen nicht unter die CSP — was im Markup steht, schon.
|
|
||||||
|
|
||||||
### Bild-Widget — Marcs Idee (29.08.2026)
|
Zwei Fallen dabei: Der Rückfall für alte Browser hängt ein `<style>` in den
|
||||||
|
Root, also muss er **nach** `innerHTML` laufen, sonst räumt das Markup ihn
|
||||||
|
gleich wieder weg. Und `el.style.…` war nie das Problem — das ist schon CSSOM.
|
||||||
|
|
||||||
|
### Bild-Widget — ✅ gebaut (30.08.2026)
|
||||||
|
|
||||||
Ein Widget, das ein Bild zeigt: hochgeladen oder als Adresse eingetragen. Genau
|
Ein Widget, das ein Bild zeigt: hochgeladen oder als Adresse eingetragen. Genau
|
||||||
**eines je Nutzer**; ein neues ersetzt das alte, ohne Verwaltung und ohne Liste.
|
**eines je Nutzer**; ein neues ersetzt das alte, ohne Verwaltung und ohne Liste.
|
||||||
@@ -339,11 +334,25 @@ Zu klären, wenn es soweit ist:
|
|||||||
- Passt zum Aufnehmen eigener App-Logos (1.3): dieselbe Ablage, dasselbe
|
- Passt zum Aufnehmen eigener App-Logos (1.3): dieselbe Ablage, dasselbe
|
||||||
Ausliefern. Zusammen bauen spart die Hälfte.
|
Ausliefern. Zusammen bauen spart die Hälfte.
|
||||||
|
|
||||||
### Notizzettel-Widget — Marcs Idee (29.08.2026)
|
**Nachtrag 31.08.2026.** Hochladen, Verlinken und Entfernen standen erst im
|
||||||
|
Widget selbst und sind in die Einstellungen gewandert (Marcs Einwand): Das
|
||||||
|
Widget zeigt, die Einstellungen stellen ein — wie beim Wetter. Damit gilt für
|
||||||
|
alle Widgets dieselbe Regel, und der Schreibtisch bleibt eine Anzeige statt
|
||||||
|
einer Bedienoberfläche.
|
||||||
|
|
||||||
|
### Notiz-Widget — ✅ gebaut (30.08.2026), Sofort-Ansicht (31.08.2026)
|
||||||
|
|
||||||
Ein Zettel auf dem Schreibtisch: hineintippen, fertig. Serverseitig je Nutzer,
|
Ein Zettel auf dem Schreibtisch: hineintippen, fertig. Serverseitig je Nutzer,
|
||||||
also auf jedem Gerät derselbe Text.
|
also auf jedem Gerät derselbe Text.
|
||||||
|
|
||||||
|
**Nachtrag 31.08.2026 — was daraus wurde.** Aus dem „Zettel" ist die *Notiz*
|
||||||
|
geworden, mit kleinem Markdown, einer Werkzeugleiste und **Sofort-Ansicht**: Der
|
||||||
|
Text ist immer gesetzt zu sehen, roh ist nur die Zeile, in der die Schreibmarke
|
||||||
|
steht. Der Weg über `contenteditable` wurde verworfen — man müsste aus dem HTML,
|
||||||
|
das der Browser beim Tippen erzeugt, den Quelltext zurückraten, und ein Fehler
|
||||||
|
dabei kostet Text. Die Grenze zu InkNote steht trotz Formatierung: ein Text,
|
||||||
|
keine Liste von Notizen, keine Ordner, keine Suche.
|
||||||
|
|
||||||
**Die Abgrenzung ist hier wichtiger als die Technik:** InkNote gibt es schon.
|
**Die Abgrenzung ist hier wichtiger als die Technik:** InkNote gibt es schon.
|
||||||
Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste,
|
Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste,
|
||||||
keine Ordner, keine Suche, keine Formatierung. Genau *ein* Textfeld, das man
|
keine Ordner, keine Suche, keine Formatierung. Genau *ein* Textfeld, das man
|
||||||
@@ -368,6 +377,54 @@ Zu klären, wenn es soweit ist:
|
|||||||
- **In der HoneyBar?** Nur lesend, und auch das erst, wenn alles unter einer
|
- **In der HoneyBar?** Nur lesend, und auch das erst, wenn alles unter einer
|
||||||
Domain liegt — schreiben kann sie von einer fremden Site aus nicht.
|
Domain liegt — schreiben kann sie von einer fremden Site aus nicht.
|
||||||
|
|
||||||
|
### Das Wappen — ✅ gebaut (02.09.2026)
|
||||||
|
|
||||||
|
Aus dem `■` ist eine Pixelbiene geworden (Marcs Idee, 01.09.2026). Vier Anläufe:
|
||||||
|
einfarbig 12 × 12 (wirkte durch den angedeuteten Streifen wie ein Grinsen),
|
||||||
|
farbig 16 × 16 von vorn, als Kasten mit geraden Kanten, und schließlich die
|
||||||
|
**Seitenansicht** nach einem gedruckten Vorbild — braune Streifen, schwarze
|
||||||
|
Beine, Flügel mit Kerbe.
|
||||||
|
|
||||||
|
Was dabei hängen blieb, steht im [README](README.md): SVG statt PNG wegen der
|
||||||
|
fremden CSPs, gelb statt mitgefärbt, und die Zeichenfassung fürs System-Fenster.
|
||||||
|
Eine 3/4-Ansicht (gerechneter Würfel, 2:1) liegt in der Schublade — sie ist
|
||||||
|
unter 64 px unbrauchbar, taugt aber für ein großes Startbild.
|
||||||
|
|
||||||
|
### Kategorien für Apps — ✅ gebaut (12.09.2026)
|
||||||
|
|
||||||
|
Überschriften über den Apps, damit aus einer Liste eine Ordnung wird.
|
||||||
|
|
||||||
|
**In zwei Anläufen gebaut, und der zweite kam von Marc.** Zuerst war die Kategorie
|
||||||
|
ein Feld im App-Formular, und die Pfeile bekamen eine Sonderregel: Am Rand einer
|
||||||
|
Gruppe kategorisieren statt tauschen. Das funktionierte, war aber die Beschreibung
|
||||||
|
eines Modells und nicht das Modell selbst. Seine Frage — warum nicht einfach eine
|
||||||
|
Überschriftenzeile in der Liste, und was darunter steht, gehört dazu? — ist die
|
||||||
|
bessere Bedienung: Man sieht, was man sortiert, das Umhängen ergibt sich von
|
||||||
|
selbst, und **Umbenennen ist eine Zeile statt eines Feldes je App**. Genau das war
|
||||||
|
der Mangel der ersten Fassung.
|
||||||
|
|
||||||
|
**Gespeichert wird trotzdem als Feld an der App.** Die Überschrift gibt es nur im
|
||||||
|
Editor. Wäre sie ein Eintrag in der App-Liste, enthielte diese Zeilen ohne ID und
|
||||||
|
ohne Adresse, und jeder Leser müsste das wissen — Kacheln, Schublade,
|
||||||
|
Erreichbarkeitsprüfung, und vor allem die HoneyBar in fremden Apps, die gerade
|
||||||
|
erst auf das Nötigste getrimmt wurde. Der Preis: Eine Überschrift ohne Apps
|
||||||
|
darunter überlebt das Speichern nicht. Das ist vertretbar — eine Kategorie besteht
|
||||||
|
durch ihre Apps.
|
||||||
|
|
||||||
|
Eingesammelt wird **im Server**, beim Schreiben wie beim Lesen. Auch das war nicht
|
||||||
|
der erste Entwurf: Zuerst sortierte die Oberfläche und die HoneyBar setzte ihre
|
||||||
|
Überschriften beim Durchlaufen — prompt stand eine App ohne Kategorie in der
|
||||||
|
HoneyBar unter der letzten Überschrift und auf dem Schreibtisch ganz oben. Bei
|
||||||
|
zwei Lesern derselben Liste gehört die Reihenfolge dorthin, wo die Liste liegt.
|
||||||
|
|
||||||
|
Offen gelassen: Schreibtischsymbole bleiben flach (Ordner wären ein eigenes
|
||||||
|
Thema), und der App-Streifen der Leiste bekommt keine Überschriften — dort ist
|
||||||
|
kein Platz.
|
||||||
|
|
||||||
|
**Falls eine leere Kategorie doch bleiben soll**, ist das das Argument für eine
|
||||||
|
echte Kategorienliste im Server — dann aber als eigenes Feld neben `apps`, nicht
|
||||||
|
als Eintrag in ihr.
|
||||||
|
|
||||||
### Eigene App-Logos — verschoben aus 1.3 (29.08.2026)
|
### Eigene App-Logos — verschoben aus 1.3 (29.08.2026)
|
||||||
|
|
||||||
**Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein
|
**Beschlossen als Planung (25.08.2026), aber nicht als Nächstes.** Je App ein
|
||||||
@@ -402,7 +459,7 @@ wenige Farben reduziert, hart gerastert) oder bunt bleiben? Ein Original-Logo
|
|||||||
mit Farbverlauf neben Pixelglyphen sieht schnell nach Fremdkörper aus. Ein
|
mit Farbverlauf neben Pixelglyphen sieht schnell nach Fremdkörper aus. Ein
|
||||||
Filter in der HoneyBar wäre billig zu haben und ließe sich abschalten.
|
Filter in der HoneyBar wäre billig zu haben und ließe sich abschalten.
|
||||||
|
|
||||||
### Widgets verschieben, Reihenfolge im Server — Marcs Idee (29.08.2026)
|
### Widgets verschieben, Reihenfolge im Server — ✅ gebaut (31.08.2026)
|
||||||
|
|
||||||
Widgets per Ziehen umsortieren, die Reihenfolge liegt beim Nutzer auf dem
|
Widgets per Ziehen umsortieren, die Reihenfolge liegt beim Nutzer auf dem
|
||||||
Server — also auf jedem Gerät gleich.
|
Server — also auf jedem Gerät gleich.
|
||||||
@@ -424,9 +481,23 @@ schon gebaut) oder am Handy Pfeiltasten statt Ziehen anbieten. Nicht vergessen:
|
|||||||
Ohne das ist die Funktion am Handy schlicht nicht da.
|
Ohne das ist die Funktion am Handy schlicht nicht da.
|
||||||
|
|
||||||
Voraussetzung ist ein Widget-Verzeichnis — heute stehen Benutzer und Wetter
|
Voraussetzung ist ein Widget-Verzeichnis — heute stehen Benutzer und Wetter
|
||||||
fest im HTML. Sinnvoll zusammen mit dem Widget-Rahmen (1.5) zu bauen, nicht
|
fest im HTML. Sinnvoll zusammen mit dem Widget-Rahmen (1.4) zu bauen, nicht
|
||||||
davor.
|
davor.
|
||||||
|
|
||||||
|
**Gebaut (31.08.2026), wie vorgeschlagen: eine Liste, beide Ansichten folgen
|
||||||
|
ihr.** Dazu ein Schalter je Widget — wer den Systemzustand nicht sehen will,
|
||||||
|
soll ihn abschalten können, ohne ihn nach unten zu schieben.
|
||||||
|
|
||||||
|
Beides steht in **einer** Einstellung: `widgets = "benutzer,notiz,-system,…"`,
|
||||||
|
das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Ein
|
||||||
|
ausgeschaltetes Widget behält seinen Platz. Damit brauchte es weder einen neuen
|
||||||
|
Endpunkt noch eine neue Tabelle — die Einstellungen liegen ohnehin je Nutzer auf
|
||||||
|
dem Server.
|
||||||
|
|
||||||
|
Ziehen ist es nicht geworden, sondern **Pfeile** — aus demselben Grund wie bei
|
||||||
|
den Apps (1.3b): Am Handy löst HTML5-Drag per Berührung nicht aus, und die Liste
|
||||||
|
ist kurz. Freie Plätze auf dem Schreibtisch bleiben offen, falls sie je fehlen.
|
||||||
|
|
||||||
### Widgets in der HoneyBar selbst
|
### Widgets in der HoneyBar selbst
|
||||||
|
|
||||||
Bisher sind Widgets als Fenster auf dem Schreibtisch gedacht. Der Schreibtisch
|
Bisher sind Widgets als Fenster auf dem Schreibtisch gedacht. Der Schreibtisch
|
||||||
@@ -434,9 +505,9 @@ ist aber die Seite, die man am seltensten offen hat — die HoneyBar dagegen ist
|
|||||||
überall. Naheliegend also: ein Knopf in der HoneyBar klappt dasselbe Widget als
|
überall. Naheliegend also: ein Knopf in der HoneyBar klappt dasselbe Widget als
|
||||||
kleines Feld auf, ohne die App zu verlassen.
|
kleines Feld auf, ohne die App zu verlassen.
|
||||||
|
|
||||||
Technisch ist das dieselbe Sache wie 1.5, nur ein zweites Zuhause für denselben
|
Technisch ist das dieselbe Sache wie 1.4, nur ein zweites Zuhause für denselben
|
||||||
Inhalt: Der Rahmen muss dann drei Formen können — Fenster, Kachel, Aufklappfeld.
|
Inhalt: Der Rahmen muss dann drei Formen können — Fenster, Kachel, Aufklappfeld.
|
||||||
Das ist beim **Bauen** von 1.5 zu bedenken, hinterher wird es teuer.
|
Das ist beim **Bauen** von 1.4 zu bedenken, hinterher wird es teuer.
|
||||||
|
|
||||||
Für Widgets, die nur sich selbst brauchen (Wetter, Bild, Uhr), ist das
|
Für Widgets, die nur sich selbst brauchen (Wetter, Bild, Uhr), ist das
|
||||||
geschenkt — sie fragen HoneyOS, nicht die App.
|
geschenkt — sie fragen HoneyOS, nicht die App.
|
||||||
@@ -445,6 +516,48 @@ geschenkt — sie fragen HoneyOS, nicht die App.
|
|||||||
|
|
||||||
## 3 · Ausbau
|
## 3 · Ausbau
|
||||||
|
|
||||||
|
### 3.x HoneyOS selbst als PWA — zurückgestellt (30.08.2026)
|
||||||
|
|
||||||
|
Heute ist HoneyOS eine Website: Es hat `theme-color`, aber kein Manifest, kein
|
||||||
|
Symbol und keinen Service Worker — installierbar ist es damit nicht. Am Handy
|
||||||
|
landet man also im Browser mit Adressleiste, während Aether und InkNote als
|
||||||
|
eigene Apps danebenliegen. Das ist genau der Bruch, den das Ganze eigentlich
|
||||||
|
verhindern soll: Der Schreibtisch sollte sich am wenigsten nach Website
|
||||||
|
anfühlen.
|
||||||
|
|
||||||
|
Was dazugehört, klein bis groß:
|
||||||
|
|
||||||
|
1. **Manifest** — Name, Startadresse, `display: standalone`, Hintergrund- und
|
||||||
|
Themenfarbe. Die Farben sollten der eingestellten Röhre folgen; sie liegen
|
||||||
|
im Server, das Manifest kann also erzeugt statt fest hinterlegt werden
|
||||||
|
(Aether macht das beim Symbol schon so).
|
||||||
|
2. **Symbol** — ein Pixelglyph in 192 und 512 px, im Amber-Look. Passt zum
|
||||||
|
Rest und ist ohne fremde Werkzeuge zu machen.
|
||||||
|
3. **Service Worker** — nötig für die Installierbarkeit. Hier ist zu
|
||||||
|
entscheiden, was er überhaupt soll (siehe unten).
|
||||||
|
|
||||||
|
**Die eigentliche Frage ist der Offline-Fall.** HoneyOS zeigt fremde Apps und
|
||||||
|
später deren Daten — ohne Netz gibt es davon nichts. Ein Schreibtisch, der
|
||||||
|
offline mit leeren Kacheln dasteht, ist schlimmer als ein ehrlicher Hinweis.
|
||||||
|
Sinnvoll wäre: Oberfläche und App-Liste im Cache, damit man die Symbole sieht
|
||||||
|
und antippen kann, und ein deutlicher Vermerk „ohne Verbindung". Widgets
|
||||||
|
bleiben leer, statt Altes von gestern als aktuell auszugeben.
|
||||||
|
|
||||||
|
**Aus dem Aether-Debakel gelernt und hier von Anfang an zu beachten:** Ein
|
||||||
|
Eintrag im Cache ist eine ganze Antwort **samt Kopfzeilen**. Wird die Startseite
|
||||||
|
vorab abgelegt, friert damit auch ihre CSP ein — und eine Änderung an der
|
||||||
|
Konfiguration erreicht den Browser nie wieder. Also Navigationen Netz zuerst,
|
||||||
|
Cache nur als Rückfall.
|
||||||
|
|
||||||
|
**Zurückgestellt (Marc, 30.08.2026): erst die Hauptfunktionen, dann das
|
||||||
|
Gehäuse.** Die PWA ist mehr Gefühl als Funktion — sie macht nichts möglich, was
|
||||||
|
heute nicht ginge, während der Widget-Rahmen darunter alles Weitere trägt.
|
||||||
|
|
||||||
|
**Reihenfolge:** Erst umziehen (siehe unten), dann installierbar machen. Ein
|
||||||
|
Ursprungswechsel macht aus einer installierten PWA eine fremde App — wer
|
||||||
|
vorher installiert, installiert zweimal.
|
||||||
|
|
||||||
|
|
||||||
- Welche Widgets wo liegen, **pro Nutzer im Server** merken — die Einstellungen
|
- Welche Widgets wo liegen, **pro Nutzer im Server** merken — die Einstellungen
|
||||||
liegen ohnehin schon dort, das ist eine Erweiterung, kein Neubau
|
liegen ohnehin schon dort, das ist eine Erweiterung, kein Neubau
|
||||||
- Widgets im Terminal-Modus
|
- Widgets im Terminal-Modus
|
||||||
|
|||||||
@@ -126,9 +126,46 @@ fragt über die Domaingrenze und bleibt dort leer.
|
|||||||
überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps
|
überall gleich: Streifen, Schublade, Kacheln, Schreibtischsymbole, Startmenü. Neue Apps
|
||||||
hängen sich hinten an.
|
hängen sich hinten an.
|
||||||
|
|
||||||
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit
|
## Kategorien
|
||||||
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
|
|
||||||
zurück, nicht als kaputter Eintrag.
|
Apps lassen sich gruppieren — *Apps*, *Dienste*, *Werkzeuge*, was du willst:
|
||||||
|
|
||||||
|
```
|
||||||
|
APPS
|
||||||
|
AETHER INKNOTE BENTOPDF
|
||||||
|
DIENSTE
|
||||||
|
GITEA POCKETID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Angelegt wird eine Kategorie als Zeile in der Liste**, mit „+ kategorie". Was darunter
|
||||||
|
steht, gehört dazu, bis die nächste Überschrift kommt — wie in einem Dokument. Die
|
||||||
|
Überschrift ist eine Zeile wie jede andere: Sie hat dieselben Pfeile ▲▼, lässt sich
|
||||||
|
umbenennen und mit × auflösen. **Ein Sonderfall zum Umhängen existiert nicht:** Schiebst du
|
||||||
|
eine App über eine Überschrift, ist sie in der anderen Gruppe, und schiebst du eine
|
||||||
|
Überschrift, wandert die Grenze.
|
||||||
|
|
||||||
|
Was **oberhalb der ersten** Überschrift steht, hat keine Kategorie und erscheint ohne
|
||||||
|
Überschrift ganz oben. Wer keine anlegt, sieht alles wie zuvor.
|
||||||
|
|
||||||
|
**Eine Kategorie besteht nur durch ihre Apps.** Gespeichert wird sie als Feld an jeder App,
|
||||||
|
nicht als eigener Eintrag — sonst enthielte die App-Liste Zeilen ohne ID und ohne Adresse,
|
||||||
|
und jeder Leser müsste das wissen: Kacheln, Schublade, Erreichbarkeitsprüfung und vor allem
|
||||||
|
die HoneyBar in fremden Apps. Zwei Folgen davon, die man kennen sollte:
|
||||||
|
|
||||||
|
- Eine Überschrift **ohne Apps darunter überlebt das Speichern nicht.** Beim Bearbeiten
|
||||||
|
bleibt sie stehen, danach ist sie weg.
|
||||||
|
- Eine Kategorie **umbenennen ist trotzdem eine Zeile**, nicht ein Feld je App — der Editor
|
||||||
|
schreibt das Feld beim Speichern an allen Apps der Gruppe.
|
||||||
|
|
||||||
|
Überschriften erscheinen dort, wo man **sucht** — Kacheln, Schublade beider Leisten,
|
||||||
|
Programme-Fenster. Nicht im App-Streifen der Leiste (32 Pixel, kein Platz) und nicht bei
|
||||||
|
den Schreibtischsymbolen (ein Schreibtisch ist flach; Ordner wären ein eigenes Thema).
|
||||||
|
|
||||||
|
**Die Reihenfolge legt der Server fest**, nicht die Oberfläche: `SetApps` sammelt Apps
|
||||||
|
derselben Kategorie zusammen, beim Schreiben wie beim Lesen. Sortierte jeder Leser für
|
||||||
|
sich, sähe dieselbe Liste an zwei Stellen verschieden aus — genau das passierte, als die
|
||||||
|
HoneyBar ihre Überschriften beim Durchlaufen setzte und eine App ohne Kategorie am Ende
|
||||||
|
unter der letzten Überschrift landete.
|
||||||
|
|
||||||
## Die Schublade
|
## Die Schublade
|
||||||
|
|
||||||
@@ -184,15 +221,177 @@ in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
|
|||||||
**Tasten:** `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
|
**Tasten:** `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
|
||||||
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
|
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
|
||||||
|
|
||||||
|
## Das Wappen
|
||||||
|
|
||||||
|
Links in beiden Leisten sitzt eine Pixelbiene — seitlich, mit braunen Streifen,
|
||||||
|
schwarzen Beinen und Flügeln mit Kerbe. Sie ersetzt das `■`, das dort seit dem
|
||||||
|
ersten Tag stand.
|
||||||
|
|
||||||
|
**Sie steht an genau einer Stelle:** `tools/biene.py`, als 16 × 16 Zeichen, ein
|
||||||
|
Zeichen je Pixel. Daraus entstehen alle Fassungen:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 tools/biene.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ausgabe | wohin |
|
||||||
|
|---|---|
|
||||||
|
| `web/img/biene.svg` | Favicon und Anmeldebildschirm |
|
||||||
|
| `web/js/biene.js` | Menüleiste des Schreibtischs (`BIENE_SVG`), System-Fenster und Startlauf (`BIENE_ASCII`) |
|
||||||
|
| `web/img/biene-256.png`, `-32.png` | Doku und Vorschau |
|
||||||
|
|
||||||
|
In die HoneyBar ist dieselbe SVG-Zeile **einkopiert** — sie ist eine einzelne
|
||||||
|
Datei ohne zweite Anfrage. Ein Test (`TestWappenIstUeberallDasselbe`) hält die
|
||||||
|
drei Kopien zusammen; wer das Raster ändert, lässt das Skript laufen und
|
||||||
|
kopiert die Zeile neu.
|
||||||
|
|
||||||
|
**Warum SVG und nicht PNG?** Die HoneyBar läuft in *fremden* Apps. Ein `<img>`
|
||||||
|
holte dort eine Datei von HoneyOS, und ob das erlaubt ist, entscheidet die CSP
|
||||||
|
der fremden App (`img-src`). Markup kann niemand blockieren, es braucht keine
|
||||||
|
zweite Anfrage, und es ist mit 2,6 kB kleiner als das PNG.
|
||||||
|
|
||||||
|
**Warum bleibt sie gelb?** In der grünen und der eisblauen Röhre wechselt alles
|
||||||
|
die Farbe mit — die Biene nicht. Ein Zeichen, das die Farbe wechselt, ist ein
|
||||||
|
Symbol wie *wolke*; eines, das seine Farbe behält, ist ein Wappen. Der eine
|
||||||
|
warme Fleck liest sich als „das hier ist das System selbst".
|
||||||
|
|
||||||
|
**Überall dasselbe Zeichen:** Leiste, Tab und Anmeldebildschirm zeigen dieselbe
|
||||||
|
Datei, nur in anderer Größe. Eine 3/4-Ansicht (ein gerechneter Würfel in
|
||||||
|
2:1-Schrägsicht) steckt als `schraeg()` in `tools/biene.py`, wird aber nirgends
|
||||||
|
ausgeliefert — sie ist unter 64 px unbrauchbar, und ein zweites Wappen für
|
||||||
|
große Flächen wäre ein zweites Wappen.
|
||||||
|
|
||||||
|
### Und aus Schriftzeichen
|
||||||
|
|
||||||
|
Im System-Fenster und über dem Startlauf steht dieselbe Biene aus `#`, `:` und
|
||||||
|
`-`. Beides sind Textflächen — ein Bild darin wäre ein Fremdkörper, wie ein
|
||||||
|
Foto in einer Tabelle. So wird aus dem System-Fenster ein **neofetch**.
|
||||||
|
|
||||||
|
Zwei Dinge stecken in der Umsetzung, die man nur durch Ausprobieren findet: Ein
|
||||||
|
Zeichen ist etwa halb so breit wie hoch, ein Pixel muss also **zwei Zeichen**
|
||||||
|
breit sein. Und Blockzeichen (`█ ▓ ▒`) sehen im Editor perfekt aus, hinterlassen
|
||||||
|
im Browser aber feine Fugen zwischen den Zellen — der Körper wird zur Waffel.
|
||||||
|
Gewöhnliche Schriftzeichen behaupten gar nicht erst, eine Fläche zu sein.
|
||||||
|
|
||||||
|
Dass ausgerechnet die **Seitenansicht** als Text funktioniert, ist kein Zufall:
|
||||||
|
Ihre Streifen laufen senkrecht, also bleibt jede Textzeile ein
|
||||||
|
zusammenhängendes Stück Körper. Von vorn zerfällt die Biene in Streifen und
|
||||||
|
liest sich wie ein Strichcode.
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
Ein Widget ist eine **Beschreibung**, kein Kasten in der `index.html`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
widgetAnmelden({
|
||||||
|
id: "wetter", titel: "wetter",
|
||||||
|
wo: ["terminal", "desktop"], // wo es erscheinen darf
|
||||||
|
sichtbar: () => true, // eigene Bedingung, optional
|
||||||
|
laden: async () => …, // false = es gibt nichts zu zeigen
|
||||||
|
leer: "verbergen", // oder ein Satz, der dann dasteht
|
||||||
|
inhalt: () => "<html>",
|
||||||
|
takt: 10 * 60 * 1000, // Nachladen, optional
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Rest macht `web/js/widgets/rahmen.js`: Im Terminal-Modus wird daraus ein
|
||||||
|
Fenster in der linken Spalte, im Desktop-Modus ein Schild oben rechts. Er kennt
|
||||||
|
vier Zustände — **lädt · fertig · nichts da · Fehler** — und hält den
|
||||||
|
Nachladetakt.
|
||||||
|
|
||||||
|
**Ein Widget, das seinen Server nicht erreicht, sagt das in seinem eigenen
|
||||||
|
Rahmen.** Es verschwindet nicht (das sähe aus wie abgeschaltet) und reißt weder
|
||||||
|
den Schreibtisch noch die anderen Widgets mit. Wo „nichts da" schlicht heißt,
|
||||||
|
dass es nichts zu zeigen gibt, kann ein Widget mit `leer: "verbergen"` ganz
|
||||||
|
wegbleiben — so macht es das Wetter ohne eingestellten Ort.
|
||||||
|
|
||||||
|
Eine neue Anzeige ist damit eine Datei in `web/js/widgets/` und ein Import in
|
||||||
|
`os.js`. Weder das Raster noch die Seite werden dafür angefasst.
|
||||||
|
|
||||||
|
### Reihenfolge und An/Aus liegen beim Nutzer
|
||||||
|
|
||||||
|
Unter *Einstellungen → Widgets* steht jedes Widget mit einem Schalter und zwei
|
||||||
|
Pfeilen. Beides zusammen steht in **einer** Einstellung, und die liegt wie alle
|
||||||
|
anderen beim Konto auf dem Server — also auf jedem Gerät gleich:
|
||||||
|
|
||||||
|
```
|
||||||
|
widgets = "benutzer,notiz,-system,wetter,bild"
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Minus heißt aus. Ein Feld statt zwei, weil beides zusammengehört: Wer ein
|
||||||
|
Widget ausschaltet, soll seinen Platz behalten, wenn er es wieder einschaltet.
|
||||||
|
Die Liste ist ein Wunsch, kein Gesetz — unbekannte Namen fallen weg, neue
|
||||||
|
Widgets hängen sich hinten an und sind an. Ein Widget, das erst später
|
||||||
|
eingeschaltet wird, holt seine Daten dann nach.
|
||||||
|
|
||||||
|
Pfeile statt Ziehen: Ziehen wäre hübscher, löst am Handy per Berührung aber
|
||||||
|
nicht aus (dieselbe Entscheidung wie bei der Reihenfolge der Apps).
|
||||||
|
|
||||||
|
## Notiz und Bild
|
||||||
|
|
||||||
|
Zwei Widgets, die nichts abfragen, sondern etwas *sind*. Beides liegt **je
|
||||||
|
Nutzer auf dem Server** — die Notiz als Text in `honeyos.json`, das Bild als
|
||||||
|
Datei in `bilder/` daneben (oder als fremde Adresse, nie beides).
|
||||||
|
|
||||||
|
### Die Notiz zeigt sich sofort gesetzt
|
||||||
|
|
||||||
|
Der Text ist **immer** formatiert zu sehen; roh ist nur die eine Zeile, in der
|
||||||
|
die Schreibmarke steht. Man tippt also in einer Aufzählung und sieht ringsum
|
||||||
|
schon Überschriften, Häkchen und Punkte — statt `- [ ] Milch` zu schreiben und
|
||||||
|
sich vorzustellen, wie das gleich aussieht.
|
||||||
|
|
||||||
|
Die Werkzeugleiste (Aufgabe · Aufzählung · Nummer · Überschrift · fett · Linie)
|
||||||
|
erscheint **erst beim Schreiben** und wirkt auf genau diese Zeile — ohne offene
|
||||||
|
Zeile hätten ihre Knöpfe nichts anzufassen, und eine Notiz, die man nur liest,
|
||||||
|
soll wie ein Text aussehen und nicht wie ein Formular. Enter führt eine Liste fort und nummeriert weiter;
|
||||||
|
Enter auf einer leeren Aufzählungszeile beendet sie. Backspace am Zeilenanfang
|
||||||
|
nimmt erst den Aufzählungspunkt, dann erst die Zeile. Häkchen lassen sich
|
||||||
|
anklicken, ohne in den Text zu springen — das ist der häufigste Griff an einer
|
||||||
|
Liste. Für größere Umbauten gibt es den Knopf **quelltext**: dann doch das ganze
|
||||||
|
Feld, roh.
|
||||||
|
|
||||||
|
**Warum nicht `contenteditable`?** Weil man aus dem, was der Browser beim Tippen
|
||||||
|
und Einfügen an HTML erzeugt, den Quelltext *zurückraten* müsste — und ein
|
||||||
|
Fehler dabei kostet Text. Hier bleibt der Quelltext die Wahrheit und wird nie
|
||||||
|
zurückgerechnet: Die Anzeige entsteht aus ihm, geändert wird immer nur eine
|
||||||
|
Zeile. Der Preis ist, dass man nicht über mehrere Zeilen hinweg markieren kann;
|
||||||
|
dafür ist der Quelltext-Knopf da.
|
||||||
|
|
||||||
|
Das kleine Markdown ist von Hand geschrieben und absichtlich klein:
|
||||||
|
Überschriften (`#`, `##`, `###`), Häkchen (`- [ ]`, `- [x]`), Aufzählungen,
|
||||||
|
nummerierte Listen, `---`, `**fett**`, `*kursiv*`, `` `code` ``. Keine
|
||||||
|
Bibliothek — HoneyOS hat keinen Bauschritt, und für sechs Regeln lohnt kein
|
||||||
|
Nachladen. Escapt wird **vor** dem Formatieren: Eine Notiz, die `<script>`
|
||||||
|
ausführt, wäre eine Falle für den Tag, an dem man etwas hineinkopiert.
|
||||||
|
|
||||||
|
**Die Notiz ist keine zweite Notiz-App.** InkNote gibt es schon: kein Titel,
|
||||||
|
keine Liste von Notizen, keine Ordner, keine Suche. Genau ein Text für das, was
|
||||||
|
nicht länger als einen Tag lebt. Gespeichert wird verzögert (und beim Verlassen
|
||||||
|
der Seite sofort); zwei offene Geräte überschreiben einander, der letzte Stand
|
||||||
|
gewinnt.
|
||||||
|
|
||||||
|
### Das Bild
|
||||||
|
|
||||||
|
Genau eines je Nutzer, hochgeladen oder verlinkt; ein neues ersetzt das alte
|
||||||
|
samt Datei. **Eingestellt wird es unter *Einstellungen → Bild*, nicht im Widget**
|
||||||
|
— wie beim Wetter, dessen Ort auch nicht in der Wetterkachel gesucht wird. Ein
|
||||||
|
Schild auf dem Schreibtisch, das zu drei Vierteln aus Knöpfen besteht, zeigt vor
|
||||||
|
allem sich selbst. Erlaubt sind PNG, JPEG, WebP und GIF — der Typ wird aus den
|
||||||
|
**Bytes** bestimmt, nicht aus dem, was der Browser behauptet. SVG ist bewusst
|
||||||
|
nicht dabei: Es kann Skripte enthalten. Ausgeliefert wird mit einem
|
||||||
|
Versionsanteil in der Adresse, sonst bliebe beim Austausch das alte Bild im
|
||||||
|
Zwischenspeicher hängen.
|
||||||
|
|
||||||
## Wetter
|
## Wetter
|
||||||
|
|
||||||
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
|
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
|
||||||
HoneyBar, in beidem oder nirgends. Zwei getrennte Schalter unter
|
HoneyBar, in beidem oder nirgends. Zwei Orte, zwei Schalter an zwei Stellen:
|
||||||
*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim
|
Auf dem Schreibtisch ist das Wetter ein Widget wie jedes andere und steht in
|
||||||
Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer
|
der Widget-Liste; für die Leiste gibt es einen eigenen Schalter unter
|
||||||
Seite, die er selten aufruft.
|
*Einstellungen → HoneyBar*. Wer es beim Arbeiten in einer App sehen will, will
|
||||||
|
es nicht zwangsläufig auch auf einer Seite, die er selten aufruft.
|
||||||
|
|
||||||
Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert;
|
Die Stadt wird unter *Einstellungen → Wetter* einmal gesucht und **mit ihren Koordinaten** gespeichert;
|
||||||
sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
|
sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
|
||||||
statt einer. Ohne Stadt bleibt das Wetter aus.
|
statt einer. Ohne Stadt bleibt das Wetter aus.
|
||||||
|
|
||||||
@@ -286,7 +485,7 @@ location / {
|
|||||||
# 2. Die CSP der App ersetzen, nicht ergänzen. Zwei CSP-Kopfzeilen bedeuten
|
# 2. Die CSP der App ersetzen, nicht ergänzen. Zwei CSP-Kopfzeilen bedeuten
|
||||||
# nicht „mehr erlaubt", sondern „beide müssen zustimmen".
|
# nicht „mehr erlaubt", sondern „beide müssen zustimmen".
|
||||||
proxy_hide_header Content-Security-Policy;
|
proxy_hide_header Content-Security-Policy;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://honeyos.example.com; connect-src 'self' https://honeyos.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://honeyos.example.com; connect-src 'self' https://honeyos.example.com; style-src 'self'; img-src 'self' data:" always;
|
||||||
|
|
||||||
# 3. Nur HTML anfassen; JSON, Bilder und Downloads bleiben unberührt.
|
# 3. Nur HTML anfassen; JSON, Bilder und Downloads bleiben unberührt.
|
||||||
sub_filter_types text/html;
|
sub_filter_types text/html;
|
||||||
@@ -301,12 +500,18 @@ Liste ist zugleich die CORS-Freigabe. **Auf den Ursprung genau**: `localhost` un
|
|||||||
oder ein abweichender Port. Passt er nicht, erscheint die HoneyBar mit leerer
|
oder ein abweichender Port. Passt er nicht, erscheint die HoneyBar mit leerer
|
||||||
App-Liste und in der Konsole steht ein CORS-Fehler.
|
App-Liste und in der Konsole steht ein CORS-Fehler.
|
||||||
|
|
||||||
**Warum `style-src 'unsafe-inline'`:** Die HoneyBar bringt ihre Stile mit, im
|
**Kein `style-src 'unsafe-inline'` nötig** (seit 11.09.2026). Die HoneyBar
|
||||||
Shadow DOM. Eine strenge CSP verbietet die — gemessen: die HoneyBar erscheint,
|
bringt ihre Stile zwar mit, hängt sie aber als konstruiertes Stylesheet
|
||||||
aber schwarz auf schwarz, ohne Röhre. Das ist eine echte Abschwächung der
|
(`adoptedStyleSheets`) in ihren Shadow DOM statt als `<style>`-Element. Nur
|
||||||
CSP dieser App; viele fertige Werkzeuge brauchen `unsafe-inline` ohnehin für
|
Markup fällt unter die CSP, über die CSSOM gesetzte Stile nicht — die App
|
||||||
sich selbst, aber wissen sollte man es. (Auflösbar, indem die HoneyBar ihre Stile
|
braucht für die HoneyBar also weder `'unsafe-inline'` noch eine Freigabe für
|
||||||
als eigene Datei ausliefert — steht in [IDEAS.md](IDEAS.md).)
|
die HoneyOS-Domain. Vorher stand die HoneyBar unter einer strengen CSP schwarz
|
||||||
|
auf schwarz da. Gemessen an einer nachgebauten App mit `style-src 'self'`:
|
||||||
|
Leiste bernstein, 32 px, keine CSP-Meldung.
|
||||||
|
|
||||||
|
Browser ohne konstruierbare Stylesheets (vor Safari 16.4, Firefox 101) fallen
|
||||||
|
auf das `<style>`-Element zurück — dort gilt der alte Stand, also `unsafe-inline`
|
||||||
|
oder eine Leiste ohne Farbe.
|
||||||
|
|
||||||
**Apps mit COEP** (alles mit WASM oder SharedArrayBuffer — BentoPDF etwa)
|
**Apps mit COEP** (alles mit WASM oder SharedArrayBuffer — BentoPDF etwa)
|
||||||
lehnen fremde Dateien ab, die sich nicht zur Einbettung bekennen. HoneyOS
|
lehnen fremde Dateien ab, die sich nicht zur Einbettung bekennen. HoneyOS
|
||||||
@@ -449,21 +654,25 @@ Das `■ HONEYOS` links ist ein Menü — auf dem Schreibtisch wie in jeder App:
|
|||||||
| in der HoneyBar (fremde App) | auf dem Schreibtisch |
|
| in der HoneyBar (fremde App) | auf dem Schreibtisch |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Schreibtisch | Über HoneyOS |
|
| Schreibtisch | Über HoneyOS |
|
||||||
| Einstellungen … | Einstellungen |
|
| HoneyBar einklappen | Einstellungen |
|
||||||
| HoneyBar einklappen | Terminal-/Desktop-Modus |
|
| — | Terminal-/Desktop-Modus |
|
||||||
|
|
||||||
Rechts daneben klappt ein Pfeil die App-Liste auf — in beiden Leisten gleich.
|
Rechts daneben klappt ein Pfeil die App-Liste auf — in beiden Leisten gleich.
|
||||||
Das Zahnrad, das dort früher stand, ist weg: Einstellungen stehen jetzt links
|
Das Zahnrad, das dort früher stand, ist weg: Auf dem Schreibtisch stehen die
|
||||||
im Systemmenü, wo sie in jeder App auch stehen.
|
Einstellungen links im Systemmenü; in einer fremden App gibt es sie nicht,
|
||||||
|
sondern nur den Weg dorthin.
|
||||||
|
|
||||||
**In den Apps sind die Punkte Links auf den Schreibtisch, keine Handlungen** —
|
**In den Apps ist „Schreibtisch" ein Link, keine Handlung** — und das ist eine
|
||||||
und das ist eine Grenze, keine Bequemlichkeit. Einstellungen schreiben, und
|
Grenze, keine Bequemlichkeit. Einstellungen schreiben, und dafür braucht der
|
||||||
dafür braucht der Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der
|
Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der Browser sie nicht
|
||||||
Browser sie nicht mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in
|
mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in einem Cookie, das
|
||||||
einem Cookie, das nur HoneyOS' eigene Seiten lesen dürfen. Also stellt die
|
nur HoneyOS' eigene Seiten lesen dürfen.
|
||||||
HoneyBar die Frage in der Adresse (`?einstellungen=1`), und der Schreibtisch
|
|
||||||
beantwortet sie: Er macht dasselbe Feld auf, das auch `[S]` öffnet, und nimmt
|
Deshalb stand dort lange ein zweiter Punkt „Einstellungen …", der auf dieselbe
|
||||||
den Anhang danach wieder aus der Adresse.
|
Seite führte, nur mit geöffnetem Feld. **Er ist weg** (01.09.2026): Zwei Türen
|
||||||
|
in denselben Raum sind keine zwei Wege. Wer etwas einstellen will, ist damit
|
||||||
|
ohnehin auf dem Schreibtisch, und dort steht das Feld einen Klick — oder `[S]` —
|
||||||
|
entfernt.
|
||||||
|
|
||||||
**Abmelden steht in den Einstellungen, ganz unten — und sonst nirgends.** Nicht
|
**Abmelden steht in den Einstellungen, ganz unten — und sonst nirgends.** Nicht
|
||||||
in der HoneyBar fremder Apps, nicht im Systemmenü, nicht im Startmenü. Es ist
|
in der HoneyBar fremder Apps, nicht im Systemmenü, nicht im Startmenü. Es ist
|
||||||
|
|||||||
+174
-2
@@ -3,14 +3,18 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
|
// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps
|
||||||
@@ -29,12 +33,30 @@ type App struct {
|
|||||||
Subtitle string `json:"subtitle,omitempty"`
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Glyph string `json:"glyph,omitempty"`
|
Glyph string `json:"glyph,omitempty"`
|
||||||
|
// Die Kategorie ist freier Text, kein Verweis auf eine Liste von
|
||||||
|
// Kategorien — es gibt keine. Eine Kategorie besteht genau so lange, wie
|
||||||
|
// eine App sie trägt; damit gibt es nichts anzulegen, nichts zu löschen
|
||||||
|
// und vor allem keine zweite Reihenfolge, die mit dieser aus dem Tritt
|
||||||
|
// geraten könnte. Die Reihenfolge der Überschriften ergibt sich aus dem
|
||||||
|
// ersten Auftreten in dieser Liste.
|
||||||
|
Kategorie string `json:"kategorie,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Picture string `json:"picture,omitempty"`
|
Picture string `json:"picture,omitempty"`
|
||||||
Settings map[string]string `json:"settings,omitempty"`
|
Settings map[string]string `json:"settings,omitempty"`
|
||||||
|
// Die Notiz. Nicht in den Einstellungen, obwohl sie dorthin passen würde:
|
||||||
|
// Die sind auf kurze Werte ausgelegt (200 Zeichen je Feld), und eine Notiz
|
||||||
|
// ist Text, kein Schalter.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
|
||||||
|
// Das Bild — entweder eine Datei neben honeyos.json oder eine fremde
|
||||||
|
// Adresse, nie beides. `Bildstand` ist der Versionsanteil in der Adresse:
|
||||||
|
// Ohne ihn bliebe beim Austausch das alte Bild im Zwischenspeicher hängen.
|
||||||
|
Bild string `json:"bild,omitempty"` // Dateiname in bilder/
|
||||||
|
BildURL string `json:"bildUrl,omitempty"` // oder eine fremde Adresse
|
||||||
|
Bildstand string `json:"bildstand,omitempty"` // gegen den Zwischenspeicher
|
||||||
}
|
}
|
||||||
|
|
||||||
type data struct {
|
type data struct {
|
||||||
@@ -52,6 +74,9 @@ const (
|
|||||||
maxApps = 24
|
maxApps = 24
|
||||||
maxSettings = 40
|
maxSettings = 40
|
||||||
maxFieldLen = 200
|
maxFieldLen = 200
|
||||||
|
// Eine Notiz, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
|
||||||
|
// muss die Grenze durchsetzen, nicht das Formular.
|
||||||
|
maxNoteLen = 4000
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -76,6 +101,10 @@ func Open(path string) (*Store, error) {
|
|||||||
if s.d.Apps == nil {
|
if s.d.Apps == nil {
|
||||||
s.d.Apps = []App{}
|
s.d.Apps = []App{}
|
||||||
}
|
}
|
||||||
|
// Auch beim Lesen einsammeln: honeyos.json darf von Hand bearbeitet werden
|
||||||
|
// (das ist der halbe Grund für eine Datei statt einer Datenbank), und dann
|
||||||
|
// stehen Kategorien schnell durcheinander.
|
||||||
|
s.d.Apps = sortiereNachKategorie(s.d.Apps)
|
||||||
if s.d.Users == nil {
|
if s.d.Users == nil {
|
||||||
s.d.Users = map[string]*User{}
|
s.d.Users = map[string]*User{}
|
||||||
}
|
}
|
||||||
@@ -119,6 +148,7 @@ func (s *Store) SetApps(apps []App) error {
|
|||||||
a.Subtitle = strings.TrimSpace(a.Subtitle)
|
a.Subtitle = strings.TrimSpace(a.Subtitle)
|
||||||
a.URL = strings.TrimSpace(a.URL)
|
a.URL = strings.TrimSpace(a.URL)
|
||||||
a.Glyph = strings.TrimSpace(a.Glyph)
|
a.Glyph = strings.TrimSpace(a.Glyph)
|
||||||
|
a.Kategorie = strings.TrimSpace(a.Kategorie)
|
||||||
|
|
||||||
if !idPattern.MatchString(a.ID) {
|
if !idPattern.MatchString(a.ID) {
|
||||||
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
|
return fmt.Errorf("App %d: ID muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1)
|
||||||
@@ -138,7 +168,7 @@ func (s *Store) SetApps(apps []App) error {
|
|||||||
if a.Glyph != "" && !knownGlyphs[a.Glyph] {
|
if a.Glyph != "" && !knownGlyphs[a.Glyph] {
|
||||||
a.Glyph = ""
|
a.Glyph = ""
|
||||||
}
|
}
|
||||||
for _, f := range []string{a.Name, a.Subtitle, a.URL} {
|
for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Kategorie} {
|
||||||
if len(f) > maxFieldLen {
|
if len(f) > maxFieldLen {
|
||||||
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
|
return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen)
|
||||||
}
|
}
|
||||||
@@ -148,10 +178,44 @@ func (s *Store) SetApps(apps []App) error {
|
|||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.d.Apps = cleaned
|
s.d.Apps = sortiereNachKategorie(cleaned)
|
||||||
return s.save()
|
return s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortiereNachKategorie sammelt Apps derselben Kategorie zusammen, ohne die
|
||||||
|
// Reihenfolge innerhalb einer Gruppe oder die der Gruppen zu ändern: Die
|
||||||
|
// Gruppen stehen in der Reihenfolge ihres ersten Auftretens, die namenlose
|
||||||
|
// immer zuerst.
|
||||||
|
//
|
||||||
|
// Das gehört hierher und nicht in die Oberfläche, weil es mehrere Leser gibt:
|
||||||
|
// der Schreibtisch, die HoneyBar in fremden Apps, irgendwann ein weiterer.
|
||||||
|
// Sortierte jeder für sich, sähe dieselbe Liste an zwei Stellen verschieden
|
||||||
|
// aus — genau das ist passiert, als die HoneyBar die Überschriften einfach
|
||||||
|
// beim Durchlaufen setzte und eine App ohne Kategorie am Ende unter der
|
||||||
|
// letzten Überschrift landete.
|
||||||
|
func sortiereNachKategorie(apps []App) []App {
|
||||||
|
ohne := make([]App, 0, len(apps))
|
||||||
|
reihenfolge := []string{}
|
||||||
|
nach := map[string][]App{}
|
||||||
|
|
||||||
|
for _, a := range apps {
|
||||||
|
if a.Kategorie == "" {
|
||||||
|
ohne = append(ohne, a)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, da := nach[a.Kategorie]; !da {
|
||||||
|
reihenfolge = append(reihenfolge, a.Kategorie)
|
||||||
|
}
|
||||||
|
nach[a.Kategorie] = append(nach[a.Kategorie], a)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ohne
|
||||||
|
for _, k := range reihenfolge {
|
||||||
|
out = append(out, nach[k]...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) User(sub string) User {
|
func (s *Store) User(sub string) User {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -182,6 +246,27 @@ func (s *Store) SeenUser(sub, name, picture string) error {
|
|||||||
return s.save()
|
return s.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note gibt die Notiz des Nutzers. Kein Rückfall auf den Standardnutzer wie
|
||||||
|
// bei den Einstellungen: Eine fremde Notiz ist kein Ersatz für die eigene.
|
||||||
|
func (s *Store) Note(sub string) string {
|
||||||
|
return s.User(sub).Note
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNote schreibt die Notiz. Zwei offene Geräte überschreiben einander — der
|
||||||
|
// letzte Stand gewinnt. Für eine Notiz ist das vertretbar; alles darüber
|
||||||
|
// bräuchte Versionen und eine Zusammenführung, und dann wäre es keine
|
||||||
|
// Kleinigkeit mehr, sondern eine Notiz-App.
|
||||||
|
func (s *Store) SetNote(sub, text string) error {
|
||||||
|
if len([]rune(text)) > maxNoteLen {
|
||||||
|
return fmt.Errorf("die Notiz fasst %d Zeichen", maxNoteLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.userLocked(sub).Note = text
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SetSettings(sub string, in map[string]string) error {
|
func (s *Store) SetSettings(sub string, in map[string]string) error {
|
||||||
if len(in) > maxSettings {
|
if len(in) > maxSettings {
|
||||||
return fmt.Errorf("zu viele Einstellungen")
|
return fmt.Errorf("zu viele Einstellungen")
|
||||||
@@ -254,5 +339,92 @@ func (s *Store) AppOrigins() map[string]bool {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bilder ────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Das Erste, was HoneyOS als Datei ablegt. Bis hierher war alles in einer
|
||||||
|
* einzigen JSON-Datei, und das soll auch so bleiben: Die Bilder liegen daneben
|
||||||
|
* in `bilder/`, die JSON kennt nur ihre Namen. Ein Nutzer hat genau **ein**
|
||||||
|
* Bild — ein neues ersetzt das alte, samt Datei.
|
||||||
|
*
|
||||||
|
* Der Dateiname ist ein Fingerabdruck der Nutzerkennung, keine Kennung im
|
||||||
|
* Klartext: In einem Ordnernamen hätte eine OIDC-`sub` nichts zu suchen, und
|
||||||
|
* manche taugen gar nicht als Dateiname.
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *Store) bilderDir() string { return filepath.Join(filepath.Dir(s.path), "bilder") }
|
||||||
|
|
||||||
|
func bildName(sub, endung string) string {
|
||||||
|
sum := sha256.Sum256([]byte(sub))
|
||||||
|
return hex.EncodeToString(sum[:8]) + endung
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bild sagt, was der Nutzer hinterlegt hat: eine Datei, eine Adresse oder
|
||||||
|
// nichts. `stand` gehört an die Adresse der Datei.
|
||||||
|
func (s *Store) Bild(sub string) (datei, url, stand string) {
|
||||||
|
u := s.User(sub)
|
||||||
|
return u.Bild, u.BildURL, u.Bildstand
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) BildPfad(sub string) (string, bool) {
|
||||||
|
datei, _, _ := s.Bild(sub)
|
||||||
|
if datei == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return filepath.Join(s.bilderDir(), datei), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBildDatei legt die Bytes ab und macht sie zum Bild des Nutzers. Ein
|
||||||
|
// vorhandenes Bild wird ersetzt — auch die alte Datei, sonst wächst der Ordner
|
||||||
|
// mit jedem Austausch.
|
||||||
|
func (s *Store) SetBildDatei(sub, endung string, daten []byte) error {
|
||||||
|
if err := os.MkdirAll(s.bilderDir(), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("bilder-ordner: %w", err)
|
||||||
|
}
|
||||||
|
name := bildName(sub, endung)
|
||||||
|
if err := os.WriteFile(filepath.Join(s.bilderDir(), name), daten, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("bild schreiben: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
u := s.userLocked(sub)
|
||||||
|
if u.Bild != "" && u.Bild != name {
|
||||||
|
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
|
||||||
|
}
|
||||||
|
u.Bild, u.BildURL = name, ""
|
||||||
|
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBildURL nimmt stattdessen eine fremde Adresse. Eine leere löscht alles.
|
||||||
|
func (s *Store) SetBildURL(sub, url string) error {
|
||||||
|
if len(url) > maxFieldLen*4 {
|
||||||
|
return fmt.Errorf("die Adresse ist zu lang")
|
||||||
|
}
|
||||||
|
if url != "" && !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||||
|
return fmt.Errorf("die Adresse muss mit http:// oder https:// beginnen")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
u := s.userLocked(sub)
|
||||||
|
if u.Bild != "" {
|
||||||
|
os.Remove(filepath.Join(s.bilderDir(), u.Bild))
|
||||||
|
}
|
||||||
|
u.Bild, u.BildURL = "", url
|
||||||
|
u.Bildstand = strconv.FormatInt(time.Now().Unix(), 36)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLocked holt (oder legt an) den Nutzer. Nur mit gehaltenem Schloss.
|
||||||
|
func (s *Store) userLocked(sub string) *User {
|
||||||
|
u, ok := s.d.Users[sub]
|
||||||
|
if !ok {
|
||||||
|
u = &User{}
|
||||||
|
s.d.Users[sub] = u
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
// DataPath nennt die eine Datei, in der alles steht.
|
// DataPath nennt die eine Datei, in der alles steht.
|
||||||
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
|
func DataPath(dir string) string { return filepath.Join(dir, "honeyos.json") }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Die Reihenfolge der Apps ist die Anzeige-Reihenfolge — für den Schreibtisch
|
||||||
|
// wie für die HoneyBar in fremden Apps. Deshalb legt der Server sie fest und
|
||||||
|
// nicht jeder Leser für sich: Als die HoneyBar ihre Überschriften noch selbst
|
||||||
|
// beim Durchlaufen setzte, landete eine App ohne Kategorie am Ende unter der
|
||||||
|
// letzten Überschrift, während der Schreibtisch sie oben zeigte.
|
||||||
|
func TestKategorienWerdenEingesammelt(t *testing.T) {
|
||||||
|
s, err := Open(filepath.Join(t.TempDir(), "honeyos.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absichtlich durcheinander: zwei Gruppen verschränkt, die App ohne
|
||||||
|
// Kategorie zuletzt.
|
||||||
|
err = s.SetApps([]App{
|
||||||
|
{ID: "a", URL: "https://e.test/a", Kategorie: "Apps"},
|
||||||
|
{ID: "g", URL: "https://e.test/g", Kategorie: "Dienste"},
|
||||||
|
{ID: "i", URL: "https://e.test/i", Kategorie: "Apps"},
|
||||||
|
{ID: "p", URL: "https://e.test/p", Kategorie: "Dienste"},
|
||||||
|
{ID: "l", URL: "https://e.test/l"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
will := []string{"l", "a", "i", "g", "p"}
|
||||||
|
hat := []string{}
|
||||||
|
for _, a := range s.Apps() {
|
||||||
|
hat = append(hat, a.ID)
|
||||||
|
}
|
||||||
|
for i := range will {
|
||||||
|
if i >= len(hat) || hat[i] != will[i] {
|
||||||
|
t.Fatalf("Reihenfolge %v, erwartet %v — ohne Kategorie zuerst, dann die Gruppen in der Reihenfolge ihres ersten Auftretens", hat, will)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und nach dem Neuladen dasselbe: Wer honeyos.json von Hand bearbeitet,
|
||||||
|
// bekommt seine Kategorien ebenfalls eingesammelt.
|
||||||
|
s2, err := Open(s.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, a := range s2.Apps() {
|
||||||
|
if a.ID != will[i] {
|
||||||
|
t.Fatalf("nach dem Neuladen %q an Stelle %d, erwartet %q", a.ID, i, will[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,22 @@ import (
|
|||||||
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
|
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
|
||||||
// Dienst für unsere Testläufe.
|
// Dienst für unsere Testläufe.
|
||||||
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
|
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
|
||||||
|
return fake(t, code, grad, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KaputtFuerTest stellt einen Dienst, der nicht antwortet — für den Fall, den
|
||||||
|
// die Oberfläche seit dem Widget-Rahmen anders behandelt als „nichts da".
|
||||||
|
func KaputtFuerTest(t *testing.T) *Dienst {
|
||||||
|
return fake(t, 0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fake(t *testing.T, code int, grad float64, kaputt bool) *Dienst {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if kaputt {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if strings.Contains(r.URL.Path, "search") {
|
if strings.Contains(r.URL.Path, "search") {
|
||||||
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
|
||||||
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
|
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -51,7 +52,7 @@ func ladeGlyphen() map[string][]string {
|
|||||||
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
|
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
|
||||||
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
|
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
|
||||||
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
|
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
|
||||||
const Version = "1.1"
|
const Version = "1.3"
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
@@ -146,6 +147,19 @@ func (s *Server) routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
|
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
|
||||||
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
|
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
|
||||||
|
// Die Notiz — nur für Angemeldete, in beide Richtungen. Sie steht bewusst
|
||||||
|
// nicht in `appsReadable`: Die HoneyBar in einer fremden App weist sich mit
|
||||||
|
// dem Schlüssel aus, und der kennt keinen Nutzer. Fremde Notizen gehen
|
||||||
|
// niemanden etwas an.
|
||||||
|
mux.HandleFunc("GET /api/note", s.requireAuth(s.handleNote))
|
||||||
|
mux.HandleFunc("PUT /api/note", s.requireAuth(s.handleSetNote))
|
||||||
|
// Das Bild: nachsehen, hochladen, verlinken, ausliefern. Vier Wege, weil
|
||||||
|
// „hochgeladene Bytes" und „fremde Adresse" verschiedene Dinge sind und
|
||||||
|
// eine Abzweigung nach Inhaltstyp im selben Endpunkt nur Rätsel aufgäbe.
|
||||||
|
mux.HandleFunc("GET /api/image/info", s.requireAuth(s.handleBildInfo))
|
||||||
|
mux.HandleFunc("POST /api/image", s.requireAuth(s.handleBildHoch))
|
||||||
|
mux.HandleFunc("PUT /api/image", s.requireAuth(s.handleBildLink))
|
||||||
|
mux.HandleFunc("GET /api/image", s.requireAuth(s.handleBild))
|
||||||
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
|
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
|
||||||
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
||||||
// Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und
|
// Dieselbe Tür wie die App-Liste: Die HoneyBar steht in einer fremden App und
|
||||||
@@ -368,6 +382,114 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string)
|
|||||||
// Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür
|
// Normalfall, solange niemand einen gewählt hat, und die HoneyBar soll dafür
|
||||||
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
|
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
|
||||||
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
|
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
|
||||||
|
func (s *Server) handleNote(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"text": s.store.Note(sub)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetNote(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
var in struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &in); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetNote(sub, in.Text); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Das Bild ──────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Genau eines je Nutzer, entweder hochgeladen oder verlinkt. Der Ausweis ist
|
||||||
|
* immer die Sitzung: Bilder gehen weder über den App-Schlüssel noch an fremde
|
||||||
|
* Ursprünge — es ist das persönlichste, was HoneyOS speichert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// bildTypen: was hereindarf, und mit welcher Endung es abgelegt wird.
|
||||||
|
//
|
||||||
|
// **SVG steht bewusst nicht dabei.** Es ist Text, kann Skripte enthalten und
|
||||||
|
// würde, aus derselben Adresse ausgeliefert, im Namen der Seite laufen. Ein
|
||||||
|
// Bildwidget ist diesen Weg nicht wert.
|
||||||
|
var bildTypen = map[string]string{
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBild = 3 << 20 // 3 MB
|
||||||
|
|
||||||
|
func (s *Server) handleBildInfo(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
datei, url, stand := s.store.Bild(sub)
|
||||||
|
switch {
|
||||||
|
case datei != "":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"art": "datei", "url": "/api/image?v=" + stand})
|
||||||
|
case url != "":
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"art": "link", "url": url})
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"art": "", "url": ""})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBildHoch(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
daten, err := io.ReadAll(io.LimitReader(r.Body, maxBild+1))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Bild nicht lesbar"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(daten) > maxBild {
|
||||||
|
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "Das Bild ist größer als 3 MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der eigene Blick auf die Bytes, nicht die Angabe des Browsers: Wer
|
||||||
|
// hochlädt, bestimmt sonst den Typ — und damit, was ausgeliefert wird.
|
||||||
|
typ := strings.SplitN(http.DetectContentType(daten), ";", 2)[0]
|
||||||
|
endung, ok := bildTypen[typ]
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{
|
||||||
|
"error": "Nur PNG, JPEG, WebP oder GIF — erkannt wurde " + typ,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetBildDatei(sub, endung, daten); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBildLink(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
var in struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &in); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetBildURL(sub, strings.TrimSpace(in.URL)); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBild(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
|
pfad, ok := s.store.BildPfad(sub)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Der Versionsanteil steht in der Adresse, also darf der Browser es lange
|
||||||
|
// behalten: Ein neues Bild bekommt eine neue Adresse.
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=604800")
|
||||||
|
http.ServeFile(w, r, pfad)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
|
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
|
||||||
set := s.store.SettingsFor(sub)
|
set := s.store.SettingsFor(sub)
|
||||||
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
|
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
|
||||||
@@ -380,7 +502,12 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub strin
|
|||||||
wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon)
|
wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("wetter: %v", err)
|
log.Printf("wetter: %v", err)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
// 502, nicht 204: Ein Ort ist eingestellt, es ist also etwas zu zeigen —
|
||||||
|
// der Dienst ist nur gerade nicht da. Die Oberflaeche unterscheidet das
|
||||||
|
// seit dem Widget-Rahmen: „nichts eingestellt" verschwindet still,
|
||||||
|
// „nicht erreichbar" sagt es. Beides als 204 zu melden hiess, dass ein
|
||||||
|
// Ausfall aussah wie ein abgeschaltetes Widget.
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Wetterdienst nicht erreichbar"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, wetter)
|
writeJSON(w, http.StatusOK, wetter)
|
||||||
|
|||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Erzeugt das HoneyOS-Wappen aus einem Textraster.
|
||||||
|
|
||||||
|
Die Biene steht **hier** und nirgends sonst: als 16 × 16 Zeichen, ein Zeichen
|
||||||
|
je Pixel. Daraus entstehen alle Fassungen, die HoneyOS braucht — SVG für die
|
||||||
|
beiden Leisten, PNG für Dokumentation und Vorschau, und eine Fassung aus
|
||||||
|
Schriftzeichen fürs System-Fenster und den Startlauf.
|
||||||
|
|
||||||
|
python3 tools/biene.py
|
||||||
|
|
||||||
|
Kein Pillow, keine Abhängigkeit: Ein PNG ist ein Kopfteil, ein zlib-Strom aus
|
||||||
|
Bildzeilen (jede mit einem Filterbyte davor) und eine Prüfsumme — das kann die
|
||||||
|
Standardbibliothek. Der Vorteil gegenüber einem Bildprogramm ist nicht die
|
||||||
|
Bequemlichkeit, sondern dass die **Quelle Text bleibt**: Ein Pixel ändern heißt
|
||||||
|
einen Buchstaben ändern, danach stimmen wieder alle Fassungen überein.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
WURZEL = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Die Farben nach dem Vorbild: Die Streifen sind **braun**. Schwarz sind nur
|
||||||
|
# Augen, Beine und Fühler — das ist der Unterschied zwischen warm und hart.
|
||||||
|
FARBEN = {
|
||||||
|
"Y": (242, 179, 61), # Honiggelb, der Körper
|
||||||
|
"L": (255, 208, 96), # Oberkante, eine Spur heller
|
||||||
|
"y": (210, 149, 42), # Unterseite im Schatten
|
||||||
|
"S": (92, 58, 28), # Streifen
|
||||||
|
"s": (70, 43, 20), # Streifen an der Unterkante
|
||||||
|
"E": (24, 20, 16), # Augenfeld und Beine
|
||||||
|
"B": (99, 199, 192), # der Lichtpunkt im Auge
|
||||||
|
"D": (59, 36, 18), # Fühler
|
||||||
|
"W": (245, 242, 231), # Flügel
|
||||||
|
"w": (214, 209, 190), # Flügel, untere Lage
|
||||||
|
}
|
||||||
|
|
||||||
|
# Von der Seite: Stachel hinten, Kopf in Laufrichtung. Diese Ansicht ist es
|
||||||
|
# geworden, weil ihre Streifen **senkrecht** laufen — deshalb bleibt sie auch
|
||||||
|
# als Text lesbar, wo die Vorderansicht in Streifen zerfällt.
|
||||||
|
BIENE = [
|
||||||
|
"....WW...WW.....",
|
||||||
|
"..WWWWW.WWWWW...",
|
||||||
|
"..WWWWWWWWWWW.D.",
|
||||||
|
"...WWWWWWWWW.DD.",
|
||||||
|
"...LLLLLLLLLLL..",
|
||||||
|
"..YYSSYYSSYYYYY.",
|
||||||
|
".YYYSSYYSSYYEBY.",
|
||||||
|
"SYYYSSYYSSYYEEY.",
|
||||||
|
".YYYSSYYSSYYYYY.",
|
||||||
|
"..yssyyssyyyyyy.",
|
||||||
|
"...EE..EE..EE...",
|
||||||
|
"...EE..EE..EE...",
|
||||||
|
"................",
|
||||||
|
"................",
|
||||||
|
"................",
|
||||||
|
"................",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die 3/4-Ansicht ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Liegt in der Schublade: HoneyOS zeigt überall dasselbe Wappen von der Seite,
|
||||||
|
# also wird sie zurzeit nirgends ausgeliefert. Wer sie doch braucht (ein großes
|
||||||
|
# Startbild, ein Aufkleber), ruft `schraeg()` und reicht das Ergebnis an `svg()`
|
||||||
|
# oder `png()`. Unter 64 px zerfällt sie — zu viele Kanten auf zu wenig Pixeln.
|
||||||
|
#
|
||||||
|
# Ein Würfel hat in Pixelkunst die Steigung 2:1: zwei Pixel nach rechts, einer
|
||||||
|
# nach unten. Von Hand wird das krumm, also werden die drei sichtbaren Flächen
|
||||||
|
# gerechnet — Deckel als Raute, darunter zwei Wangen. Wichtig ist, **zeilenweise**
|
||||||
|
# zu rechnen: Wer die Raute Punkt für Punkt setzt, lässt Löcher, weil zwei
|
||||||
|
# Schritte nach rechts nur einen nach unten kosten.
|
||||||
|
def wuerfel(breite, wand):
|
||||||
|
hoehe = breite // 2
|
||||||
|
mitte = breite / 2
|
||||||
|
deckel = {
|
||||||
|
(x, y)
|
||||||
|
for y in range(hoehe)
|
||||||
|
for x in range(breite)
|
||||||
|
if abs(x + 0.5 - mitte) / 2 + abs(y + 0.5 - hoehe / 2) <= hoehe / 2
|
||||||
|
}
|
||||||
|
unterkante = {}
|
||||||
|
for (x, y) in deckel:
|
||||||
|
unterkante[x] = max(unterkante.get(x, -1), y)
|
||||||
|
flaechen = {q: "T" for q in deckel}
|
||||||
|
for x, yb in unterkante.items():
|
||||||
|
for t in range(1, wand + 1):
|
||||||
|
flaechen[(x, yb + t)] = "V" if x < mitte else "R" # vorn / rechts
|
||||||
|
return flaechen, unterkante, mitte
|
||||||
|
|
||||||
|
|
||||||
|
def schraeg(breite=22, wand=10, dx=5, dy=9, n=32, band=5):
|
||||||
|
flaechen, unterkante, mitte = wuerfel(breite, wand)
|
||||||
|
g = [["." for _ in range(n)] for _ in range(n)]
|
||||||
|
|
||||||
|
def setz(x, y, c):
|
||||||
|
if 0 <= x + dx < n and 0 <= y + dy < n:
|
||||||
|
g[y + dy][x + dx] = c
|
||||||
|
|
||||||
|
# Die Streifen laufen parallel zur linken Deckelkante (u = x - 2y) und
|
||||||
|
# setzen sich auf der rechten Wange als senkrechte Bänder fort: dieselbe
|
||||||
|
# Rechnung, ausgewertet an der Kante, an der die Wange beginnt. Deshalb
|
||||||
|
# passen sie über den Knick hinweg zusammen.
|
||||||
|
streifig = lambda x, y: ((x - 2 * y) // band) % 2 == 0
|
||||||
|
for (x, y), art in sorted(flaechen.items()):
|
||||||
|
if art == "T":
|
||||||
|
setz(x, y, "S" if streifig(x, y) else "L")
|
||||||
|
elif art == "R":
|
||||||
|
setz(x, y, "s" if streifig(x, unterkante[x]) else "y")
|
||||||
|
else:
|
||||||
|
setz(x, y, "Y")
|
||||||
|
|
||||||
|
# Augen: Rechtecke in Flächenkoordinaten — auf dem Schirm werden daraus
|
||||||
|
# Parallelogramme, genau wie beim Vorbild.
|
||||||
|
for ax in (2, 7):
|
||||||
|
for x in range(ax, ax + 3):
|
||||||
|
if x in unterkante:
|
||||||
|
for t in (3, 4, 5):
|
||||||
|
setz(x, unterkante[x] + t, "E")
|
||||||
|
if ax + 1 in unterkante:
|
||||||
|
setz(ax + 1, unterkante[ax + 1] + 4, "B")
|
||||||
|
|
||||||
|
for k in range(3): # zwei Flügel in derselben Schräge
|
||||||
|
for b in range(6):
|
||||||
|
setz(3 + 2 * k + b, -2 + k, "W")
|
||||||
|
setz(10 + 2 * k + b, -4 + k, "w")
|
||||||
|
for k in range(2): # kurze Fühler am Deckelrand
|
||||||
|
setz(int(mitte) - 5 - k, -3 - k, "D")
|
||||||
|
setz(int(mitte) + 6 + k, -5 - k, "D")
|
||||||
|
return ["".join(z) for z in g]
|
||||||
|
|
||||||
|
|
||||||
|
def svg(raster):
|
||||||
|
"""Waagerechte Läufe gleicher Farbe werden ein Rechteck — dieselbe Idee wie
|
||||||
|
bei den Pixelsymbolen in web/js/glyphs.js, nur mit Farbe statt currentColor.
|
||||||
|
|
||||||
|
Der Rahmen wird auf das gesetzt, was wirklich gefüllt ist: Das Raster ist
|
||||||
|
quadratisch, die Biene ist es nicht. Mit dem vollen Quadrat als viewBox
|
||||||
|
bliebe unten ein Viertel Luft — und die kostet überall Größe, wo das Bild in
|
||||||
|
einen Kasten gelegt wird: in der Leiste, im Tab, auf dem Anmeldebildschirm.
|
||||||
|
|
||||||
|
Warum SVG und nicht PNG für die Leisten: Die HoneyBar läuft in *fremden*
|
||||||
|
Apps. Ein <img> holt dort eine Datei von HoneyOS, und ob das erlaubt ist,
|
||||||
|
entscheidet die CSP der fremden App. Markup kann niemand blockieren."""
|
||||||
|
teile = []
|
||||||
|
for y, zeile in enumerate(raster):
|
||||||
|
x = 0
|
||||||
|
while x < len(zeile):
|
||||||
|
z = zeile[x]
|
||||||
|
if z not in FARBEN:
|
||||||
|
x += 1
|
||||||
|
continue
|
||||||
|
lauf = 1
|
||||||
|
while x + lauf < len(zeile) and zeile[x + lauf] == z:
|
||||||
|
lauf += 1
|
||||||
|
r, g, b = FARBEN[z]
|
||||||
|
teile.append(f'<rect x="{x}" y="{y}" width="{lauf}" height="1" fill="#{r:02x}{g:02x}{b:02x}"/>')
|
||||||
|
x += lauf
|
||||||
|
voll = [(x, y) for y, z in enumerate(raster) for x, c in enumerate(z) if c in FARBEN]
|
||||||
|
x0 = min(x for x, _ in voll)
|
||||||
|
y0 = min(y for _, y in voll)
|
||||||
|
breite = max(x for x, _ in voll) - x0 + 1
|
||||||
|
hoehe = max(y for _, y in voll) - y0 + 1
|
||||||
|
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x0} {y0} {breite} {hoehe}" '
|
||||||
|
'shape-rendering="crispEdges" aria-hidden="true">' + "".join(teile) + "</svg>")
|
||||||
|
|
||||||
|
|
||||||
|
def png(raster, faktor, ziel):
|
||||||
|
"""Vergrößert wird um ganze Faktoren, damit jedes Pixel ein Quadrat bleibt."""
|
||||||
|
n = len(raster)
|
||||||
|
roh = bytearray()
|
||||||
|
for zeile in raster:
|
||||||
|
pixel = bytearray()
|
||||||
|
for zeichen in zeile:
|
||||||
|
r, g, b = FARBEN.get(zeichen, (0, 0, 0))
|
||||||
|
pixel += bytes((r, g, b, 255 if zeichen in FARBEN else 0)) * faktor
|
||||||
|
for _ in range(faktor):
|
||||||
|
roh += b"\x00" + pixel # Filterbyte 0: keine Vorhersage
|
||||||
|
|
||||||
|
def block(art, daten):
|
||||||
|
return (struct.pack(">I", len(daten)) + art + daten
|
||||||
|
+ struct.pack(">I", zlib.crc32(art + daten) & 0xFFFFFFFF))
|
||||||
|
|
||||||
|
kopf = struct.pack(">IIBBBBB", n * faktor, n * faktor, 8, 6, 0, 0, 0)
|
||||||
|
ziel.write_bytes(b"\x89PNG\r\n\x1a\n" + block(b"IHDR", kopf)
|
||||||
|
+ block(b"IDAT", zlib.compress(bytes(roh), 9)) + block(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
# Ein Zeichen ist etwa halb so breit wie hoch — **ein Pixel sind zwei Zeichen**,
|
||||||
|
# sonst steht die Biene hochkant. Blockzeichen (█ ▓ ▒) wären naheliegend,
|
||||||
|
# kacheln aber nicht: Zwischen den Zellen bleiben Fugen, und der Körper wird zur
|
||||||
|
# Waffel. Gewöhnliche Schriftzeichen behaupten gar nicht erst, eine Fläche zu
|
||||||
|
# sein — deshalb sehen sie besser aus.
|
||||||
|
ZEICHEN = {"Y": "#", "L": "#", "y": "#", "S": ":", "s": ":", "E": ":", "D": ":", "W": "-", "B": "o"}
|
||||||
|
|
||||||
|
|
||||||
|
def text(raster):
|
||||||
|
zeilen = ["".join(ZEICHEN.get(c, " ") * 2 for c in z).rstrip() for z in raster]
|
||||||
|
while zeilen and not zeilen[0]:
|
||||||
|
zeilen.pop(0)
|
||||||
|
while zeilen and not zeilen[-1]:
|
||||||
|
zeilen.pop()
|
||||||
|
return "\n".join(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
bild = svg(BIENE)
|
||||||
|
zeichen = text(BIENE)
|
||||||
|
|
||||||
|
(WURZEL / "web/img/biene.svg").write_text(bild + "\n")
|
||||||
|
png(BIENE, 16, WURZEL / "web/img/biene-256.png")
|
||||||
|
png(BIENE, 2, WURZEL / "web/img/biene-32.png")
|
||||||
|
|
||||||
|
# Für die Oberfläche als Modul: Das Wappen im Markup (die Leiste holt kein
|
||||||
|
# Bild nach) und die Zeichenfassung fürs System-Fenster.
|
||||||
|
(WURZEL / "web/js/biene.js").write_text(
|
||||||
|
"/* Erzeugt von tools/biene.py — nicht von Hand ändern.\n"
|
||||||
|
" *\n"
|
||||||
|
" * Das Wappen als Markup statt als Bilddatei: So kommt es ohne zweite\n"
|
||||||
|
" * Anfrage aus und lässt sich mit dem Text daneben ausrichten.\n"
|
||||||
|
" */\n"
|
||||||
|
# Einfache Anführungszeichen: So steht das Markup wörtlich da und ist
|
||||||
|
# mit web/img/biene.svg vergleichbar — ein Test hält beides gleich.
|
||||||
|
f"export const BIENE_SVG = '{bild}';\n\n"
|
||||||
|
f"export const BIENE_ASCII = {json.dumps(zeichen)};\n"
|
||||||
|
)
|
||||||
|
bar = WURZEL / "web/honeybar/honeybar.js"
|
||||||
|
alt = bar.read_text()
|
||||||
|
neu = re.sub(r" const WAPPEN = '[^\n]*';", f" const WAPPEN = '{bild}';", alt)
|
||||||
|
if neu != alt:
|
||||||
|
bar.write_text(neu)
|
||||||
|
print("web/honeybar/honeybar.js: Wappen ersetzt")
|
||||||
|
|
||||||
|
print(text(BIENE))
|
||||||
|
print(f"\nweb/img/biene.svg ({len(bild)} Zeichen), biene-256.png, biene-32.png, web/js/biene.js")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+376
-26
@@ -202,7 +202,24 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.mb-logo b { font-weight: 400; color: var(--p-mid); }
|
.mb-logo b { font-weight: 400; color: var(--p-mid); }
|
||||||
.mb-logo .hex { font-size: 10px; vertical-align: 2px; }
|
/* Das Wappen bleibt gelb, während der Rest die Röhre wechselt: Ein Zeichen,
|
||||||
|
das die Farbe mitwechselt, ist ein Symbol wie „wolke" — eines, das seine
|
||||||
|
Farbe behält, ist ein Wappen. */
|
||||||
|
.mb-logo .hex { display: inline-block; width: 20px; height: 20px; vertical-align: -6px; }
|
||||||
|
.mb-logo .hex svg { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* Die Biene aus Schriftzeichen — im System-Fenster und über dem Startlauf.
|
||||||
|
Ein Zeichen ist halb so breit wie hoch, deshalb ist im Raster jeder Pixel
|
||||||
|
zwei Zeichen breit (siehe tools/biene.py). */
|
||||||
|
.biene-text {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--p);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.boot-biene { font-size: 11px; margin-bottom: 14px; }
|
||||||
|
|
||||||
.mb-apps {
|
.mb-apps {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -284,14 +301,11 @@ body {
|
|||||||
grid-template-columns: 320px minmax(0, 1fr);
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
/* Ohne feste Plätze verteilt das Raster die drei Bereiche der Reihe nach —
|
/* Zwei Bereiche, keine Einzelplätze mehr: Links die Widgetspalte, rechts
|
||||||
das Wetter landete dann neben „system" und schob die Kacheln in die
|
die Apps. Vorher hatte jedes Widget seinen festen Platz im Raster — beim
|
||||||
schmale linke Spalte. Links stehen die Anzeigen untereinander, rechts
|
dritten hätte man das Raster wieder anfassen müssen. */
|
||||||
gehören die Apps, über die volle Höhe. */
|
.widgetspalte { grid-column: 1; }
|
||||||
.win-user { grid-column: 1; grid-row: 1; }
|
.tiles { grid-column: 2; }
|
||||||
.win-status { grid-column: 1; grid-row: 2; }
|
|
||||||
.win-weather { grid-column: 1; grid-row: 3; }
|
|
||||||
.tiles { grid-column: 2; grid-row: 1 / span 3; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fenster */
|
/* Fenster */
|
||||||
@@ -493,6 +507,62 @@ body {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Einstellungen: Abschnitte statt einer langen Spalte ───────────────────
|
||||||
|
Beschriftung links, Schalter rechts. Bei zwölf Einstellungen untereinander
|
||||||
|
sucht man sonst; mit Überschriften weiß man, wo man ist. */
|
||||||
|
.sek { margin-bottom: 18px; }
|
||||||
|
.sek + .sek { border-top: 1px solid var(--p-faint); padding-top: 14px; }
|
||||||
|
.sek-titel {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--p);
|
||||||
|
}
|
||||||
|
.sek-hinweis { margin: 8px 0 0; color: var(--p-dim); font-size: 11px; line-height: 1.4; }
|
||||||
|
.reihe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.reihe .r-label { font-size: 12.5px; color: var(--p-mid); }
|
||||||
|
.reihe .seg { justify-content: flex-end; }
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.reihe { flex-direction: column; align-items: stretch; gap: 4px; }
|
||||||
|
.reihe .seg { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Widget-Liste: an/aus, Name, Pfeile — dieselben Pfeile wie bei den Apps. */
|
||||||
|
.wl { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.wl-zeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--p-faint);
|
||||||
|
padding: 5px 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.wl-zeile.wl-aus .wl-name { color: var(--p-dim); }
|
||||||
|
.wl-an {
|
||||||
|
background: var(--p);
|
||||||
|
border: 1px solid var(--p);
|
||||||
|
color: var(--bg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
text-shadow: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
.wl-an[aria-pressed="false"] { background: none; border-color: var(--p-dim); color: var(--p-dim); text-shadow: inherit; }
|
||||||
|
.wl-name { flex: 1; min-width: 0; font-size: 13px; color: var(--p-mid); }
|
||||||
|
.wl-name em { font-style: normal; color: var(--p-dim); font-size: 11px; margin-left: 6px; }
|
||||||
|
.wl .ae-move { margin-left: 0; }
|
||||||
|
|
||||||
.seg { display: flex; flex-wrap: wrap; gap: 6px; }
|
.seg { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.seg button {
|
.seg button {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -788,15 +858,9 @@ body {
|
|||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Abmelden steht unten in den Einstellungen, abgesetzt vom Rest: Es ist die
|
/* Abmelden steht unten in den Einstellungen, im letzten Abschnitt: Es ist die
|
||||||
einzige Schaltfläche dort, die nicht etwas einstellt, sondern etwas beendet. */
|
einzige Schaltfläche dort, die nicht etwas einstellt, sondern etwas beendet. */
|
||||||
.field-out {
|
.btn-out { width: 100%; color: var(--p-mid); margin-top: 10px; }
|
||||||
border-top: 1px solid var(--p-faint);
|
|
||||||
padding-top: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.btn-out { width: 100%; color: var(--p-mid); }
|
|
||||||
.btn-out:hover { color: var(--bg); background: var(--p); }
|
.btn-out:hover { color: var(--bg); background: var(--p); }
|
||||||
.startmenu .sm-head {
|
.startmenu .sm-head {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -880,8 +944,26 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dasselbe Wappen wie in der Leiste, nur groß. Es schwebt ein wenig — sechs
|
||||||
|
Sekunden für zwei Pixel, also eher ein Atmen als eine Bewegung. Wer Bewegung
|
||||||
|
abgestellt hat, bekommt es ruhig. */
|
||||||
|
.login-biene {
|
||||||
|
display: block;
|
||||||
|
width: clamp(128px, 28vw, 176px);
|
||||||
|
height: auto;
|
||||||
|
margin: 22px 0 0;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
animation: schweben 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes schweben {
|
||||||
|
50% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.login-biene { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.login-box {
|
.login-box {
|
||||||
margin-top: 26px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1028,9 +1110,7 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Terminal-Modus: ein Fenster wie die anderen. Schmal gehalten — über die
|
|
||||||
volle Breite gezogen sähe eine Temperatur nach Bekanntmachung aus. */
|
|
||||||
.win-weather { max-width: 320px; align-self: start; }
|
|
||||||
|
|
||||||
|
|
||||||
/* Schreibtisch-Modus: schwebendes Schild, rechts unter der Menüleiste. Nicht
|
/* Schreibtisch-Modus: schwebendes Schild, rechts unter der Menüleiste. Nicht
|
||||||
@@ -1101,16 +1181,205 @@ body {
|
|||||||
.uw-name { color: var(--p); font-size: 15px; }
|
.uw-name { color: var(--p); font-size: 15px; }
|
||||||
.uw-gruss { color: var(--p-mid); }
|
.uw-gruss { color: var(--p-mid); }
|
||||||
.uw-host { color: var(--p-dim); font-size: 12px; }
|
.uw-host { color: var(--p-dim); font-size: 12px; }
|
||||||
.win-user { max-width: 320px; align-self: start; }
|
/* Die Spalte selbst hält die Breite — vorher trug sie jedes Widget einzeln,
|
||||||
|
und ein neues ohne diese Zeile stand quer. */
|
||||||
|
.widgetspalte {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: clamp(14px, 3vw, 28px);
|
||||||
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 860px) {
|
||||||
|
.widgetspalte { max-width: 320px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die vier Zustände, die der Rahmen kennt. „lädt" und „nicht erreichbar"
|
||||||
|
stehen im selben Kasten wie der Inhalt — ein Widget, das bei einem Fehler
|
||||||
|
verschwindet, sieht aus wie eines, das man nie eingeschaltet hat. */
|
||||||
|
.w-zustand { color: var(--p-dim); margin: 0; font-size: 12.5px; }
|
||||||
|
.w-fehler { color: var(--p-mid); }
|
||||||
|
|
||||||
|
/* ── Notiz ─────────────────────────────────────────────────────────────────
|
||||||
|
* Gesetzt ist der Normalfall — roh ist nur die Zeile, in der man steht (und im
|
||||||
|
* Quelltext-Modus das ganze Feld). Beide sind gleich hoch, damit der Kasten
|
||||||
|
* beim Umschalten nicht springt. */
|
||||||
|
.notiz-text,
|
||||||
|
.notiz-feld {
|
||||||
|
min-height: 112px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.notiz-feld {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--p-dim);
|
||||||
|
color: var(--p);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.notiz-feld:focus { outline: none; border-color: var(--p); }
|
||||||
|
|
||||||
|
.notiz-text { font-size: 13px; line-height: 1.45; color: var(--p-mid); }
|
||||||
|
.notiz-text h3,
|
||||||
|
.notiz-text h4,
|
||||||
|
.notiz-text h5 {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
color: var(--p);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.notiz-text h3 { font-size: 13px; }
|
||||||
|
.notiz-text h4 { font-size: 12px; }
|
||||||
|
.notiz-text h5 { font-size: 11px; color: var(--p-mid); }
|
||||||
|
.notiz-text > :first-child { margin-top: 0; }
|
||||||
|
.notiz-text p { margin: 0 0 6px; }
|
||||||
|
.notiz-text ul,
|
||||||
|
.notiz-text ol { margin: 0 0 6px; padding-left: 18px; }
|
||||||
|
.notiz-text li { margin: 1px 0; }
|
||||||
|
.notiz-text code {
|
||||||
|
background: var(--p-faint);
|
||||||
|
padding: 0 3px;
|
||||||
|
color: var(--p);
|
||||||
|
}
|
||||||
|
.notiz-text b { color: var(--p); font-weight: 400; }
|
||||||
|
.notiz-text hr { border: 0; border-top: 1px solid var(--p-faint); margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Häkchen: anklickbar, ohne in den Text zu springen. Die Aufzählungspunkte
|
||||||
|
fallen weg — das Kästchen ist der Punkt. */
|
||||||
|
.notiz-text .notiz-hak { list-style: none; margin-left: -18px; }
|
||||||
|
.notiz-text .notiz-hak label {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notiz-text .notiz-hak input {
|
||||||
|
appearance: none;
|
||||||
|
flex: none;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border: 1px solid var(--p-dim);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.notiz-text .notiz-hak input:checked { background: var(--p); border-color: var(--p); }
|
||||||
|
.notiz-text .notiz-hak.ab span { color: var(--p-dim); text-decoration: line-through; }
|
||||||
|
.notiz-text i { color: var(--p-mid); font-style: italic; }
|
||||||
|
|
||||||
|
/* Die Zeile, in der die Schreibmarke steht: als Feld, aber ohne wie eines
|
||||||
|
auszusehen. Sie soll an der Stelle sitzen, an der eben noch der gesetzte
|
||||||
|
Text stand — gleiche Schrift, gleiche Höhe, nur ein Streifen links zeigt,
|
||||||
|
dass hier gerade der Quelltext steht. */
|
||||||
|
.notiz-zeile {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--p-faint);
|
||||||
|
border: 0;
|
||||||
|
border-left: 2px solid var(--p);
|
||||||
|
color: var(--p);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 1px 4px 1px 6px;
|
||||||
|
margin: 1px 0;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.notiz-zeile:focus { outline: none; }
|
||||||
|
.notiz-rohli { list-style: none; margin-left: -18px; }
|
||||||
|
.notiz-leer { cursor: text; }
|
||||||
|
|
||||||
|
/* Werkzeugleiste: klein, einreihig, immer da. Sie wirkt auf die Zeile, in der
|
||||||
|
man steht — deshalb steht sie oben und nicht neben dem Knopf zum Umschalten. */
|
||||||
|
.notiz-werkzeuge { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||||
|
.notiz-wz {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--p-faint);
|
||||||
|
color: var(--p-mid);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 24px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.notiz-wz:hover:not(:disabled) { border-color: var(--p); color: var(--p); }
|
||||||
|
.notiz-wz:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.nw-fett { font-weight: 700; }
|
||||||
|
|
||||||
|
.w-fuss {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.w-stand { color: var(--p-dim); font-size: 11px; }
|
||||||
|
.w-fuss .btn { font-size: 12px; padding: 3px 9px; }
|
||||||
|
|
||||||
|
/* ── Bild ──────────────────────────────────────────────────────────────────
|
||||||
|
* Fester Rahmen, das Bild füllt ihn. Verzerren wäre die Alternative — und
|
||||||
|
* Zuschneiden von Hand ein eigenes Stück Arbeit. */
|
||||||
|
.bildw {
|
||||||
|
border: 1px solid var(--p-faint);
|
||||||
|
background: var(--bg);
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bildw-bild { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.bildw .w-zustand { padding: 0 12px; text-align: center; }
|
||||||
|
|
||||||
|
/* Die Knöpfe stehen in den Einstellungen, nicht im Widget: links eine kleine
|
||||||
|
Vorschau, rechts was man damit tun kann. */
|
||||||
|
.bildset { display: flex; gap: 10px; align-items: flex-start; }
|
||||||
|
.bildset-vorschau {
|
||||||
|
flex: none;
|
||||||
|
width: 84px;
|
||||||
|
height: 56px;
|
||||||
|
border: 1px solid var(--p-faint);
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bildset-bild { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.bildset-leer { color: var(--p-dim); font-size: 11px; }
|
||||||
|
.bildset-rechts { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.bildset-knoepfe { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.bildset-knoepfe .btn { font-size: 12px; padding: 3px 9px; }
|
||||||
|
|
||||||
|
.bildw-linkzeile { display: flex; gap: 6px; margin-top: 8px; }
|
||||||
|
.bildw-linkzeile input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--p-dim);
|
||||||
|
color: var(--p);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.bildw-linkzeile input:focus { outline: none; border-color: var(--p); }
|
||||||
|
|
||||||
/* Einspaltig (Handy, schmales Fenster) sollen die Widgets so breit sein wie das
|
/* Einspaltig (Handy, schmales Fenster) sollen die Widgets so breit sein wie das
|
||||||
Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein
|
Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein
|
||||||
Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt —
|
Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt —
|
||||||
eine Medienabfrage erhöht die Spezifität nicht. */
|
eine Medienabfrage erhöht die Spezifität nicht. */
|
||||||
@media (max-width: 859px) {
|
|
||||||
.win-weather,
|
|
||||||
.win-user { max-width: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Die Schilder auf dem Schreibtisch stapeln sich oben rechts. */
|
/* Die Schilder auf dem Schreibtisch stapeln sich oben rechts. */
|
||||||
.deskwidgets {
|
.deskwidgets {
|
||||||
@@ -1127,3 +1396,84 @@ body {
|
|||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.deskwidgets { position: static; align-items: stretch; margin: 10px; }
|
.deskwidgets { position: static; align-items: stretch; margin: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Kategorien ────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Eine Überschrift je Gruppe, in derselben Sprache wie die übrigen Etiketten:
|
||||||
|
* klein, gesperrt, gedimmt. Sie soll die Kacheln gliedern, nicht mit ihnen um
|
||||||
|
* Aufmerksamkeit ringen.
|
||||||
|
*/
|
||||||
|
.tiles-kopf {
|
||||||
|
/* Bleibt ein Kind desselben Rasters und spannt über alle Spalten — ein
|
||||||
|
eigenes Raster je Gruppe hätte die Kacheln zweier Gruppen gegeneinander
|
||||||
|
verschoben. */
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 6px 0 -4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: normal;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--p-dim);
|
||||||
|
}
|
||||||
|
.tiles-kopf:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.applist .al-kopf {
|
||||||
|
padding: 8px 8px 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--p-dim);
|
||||||
|
}
|
||||||
|
.applist .al-kopf:first-child { padding-top: 2px; }
|
||||||
|
|
||||||
|
/* Im Editor ist die Überschrift eine Zeile wie jede andere: Sie lässt sich
|
||||||
|
umbenennen, verschieben und auflösen. Deshalb sitzt hier ein Eingabefeld und
|
||||||
|
kein Text — Umbenennen ist eine Zeile statt eines Feldes je App. */
|
||||||
|
.ae-gruppe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 4px 3px;
|
||||||
|
}
|
||||||
|
.ae-gruppe:first-child { padding-top: 2px; }
|
||||||
|
|
||||||
|
.ae-kat {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom-color: var(--p-faint);
|
||||||
|
color: var(--p-mid);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 3px 4px;
|
||||||
|
}
|
||||||
|
.ae-kat::placeholder { color: var(--p-dim); text-transform: none; letter-spacing: normal; }
|
||||||
|
.ae-kat:focus { outline: none; border-color: var(--p-dim); background: var(--p-faint); color: var(--p); }
|
||||||
|
|
||||||
|
.ae-kat-weg {
|
||||||
|
flex: none;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--p-dim);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ae-kat-weg:hover,
|
||||||
|
.ae-kat-weg:focus-visible { color: var(--p); border-color: var(--p-dim); }
|
||||||
|
|
||||||
|
/* Die Schublade ist ein Raster mit drei Spalten — die Überschrift spannt
|
||||||
|
darüber, sonst stünde sie als vierte Kachel neben den Apps. */
|
||||||
|
.drawer .d-kopf {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 7px 4px 1px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--p-dim);
|
||||||
|
}
|
||||||
|
.drawer .d-kopf:first-child { padding-top: 0; }
|
||||||
+122
-41
@@ -98,10 +98,44 @@
|
|||||||
"display:block",
|
"display:block",
|
||||||
"color-scheme:dark",
|
"color-scheme:dark",
|
||||||
].join(";");
|
].join(";");
|
||||||
|
/* Das Wappen. Es steht hier als Markup, weil die HoneyBar in fremden
|
||||||
|
* Apps läuft: Ein <img> holte eine Datei von HoneyOS, und ob das geht,
|
||||||
|
* entscheidet die CSP der fremden App. Erzeugt von tools/biene.py —
|
||||||
|
* dieselbe Zeile steht in web/img/biene.svg (ein Test hält sie gleich).
|
||||||
|
*/
|
||||||
|
const WAPPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>';
|
||||||
|
|
||||||
|
/* ── Stile ohne CSP-Ausnahme ──────────────────────────────────────────────
|
||||||
|
* Die Stile kommen als konstruiertes Stylesheet in den Shadow-Root, nicht
|
||||||
|
* als style-Element. Der Unterschied ist nicht Geschmack, sondern der
|
||||||
|
* ganze Punkt: Ein style-Element ist Markup und faellt damit unter die CSP der
|
||||||
|
* fremden App — die musste deshalb bis hierher style-src 'unsafe-inline'
|
||||||
|
* erlauben, sonst stand die HoneyBar schwarz auf schwarz da. Ueber die
|
||||||
|
* CSSOM gesetzte Stile fallen nicht darunter. So braucht die Gastgeber-App
|
||||||
|
* fuer die HoneyBar gar keine style-src-Erlaubnis mehr, und es bleibt
|
||||||
|
* trotzdem bei einer Datei ohne zweite Anfrage.
|
||||||
|
*
|
||||||
|
* Der Rueckfall ist fuer Browser ohne konstruierbare Stylesheets (vor
|
||||||
|
* Safari 16.4, Firefox 101): Dort steht es wie bisher, also nicht
|
||||||
|
* schlechter — nur eben weiter mit 'unsafe-inline'.
|
||||||
|
*/
|
||||||
|
function stileAnlegen(wurzel, css) {
|
||||||
|
try {
|
||||||
|
const blatt = new CSSStyleSheet();
|
||||||
|
blatt.replaceSync(css);
|
||||||
|
wurzel.adoptedStyleSheets = [blatt];
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* alter Browser: unten der Weg ueber das Markup */
|
||||||
|
}
|
||||||
|
const el = document.createElement("style");
|
||||||
|
el.textContent = css;
|
||||||
|
wurzel.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
const root = host.attachShadow({ mode: "open" });
|
const root = host.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
root.innerHTML = `
|
const STIL = `
|
||||||
<style>
|
|
||||||
:host, * { box-sizing: border-box; }
|
:host, * { box-sizing: border-box; }
|
||||||
/* Dieselben Röhren wie auf dem Schreibtisch. Welche gilt, sagt der
|
/* Dieselben Röhren wie auf dem Schreibtisch. Welche gilt, sagt der
|
||||||
Server; bis seine Antwort da ist, leuchtet Bernstein. */
|
Server; bis seine Antwort da ist, leuchtet Bernstein. */
|
||||||
@@ -136,16 +170,20 @@
|
|||||||
}
|
}
|
||||||
.home { letter-spacing: .14em; color: var(--p); padding-left: 2px; }
|
.home { letter-spacing: .14em; color: var(--p); padding-left: 2px; }
|
||||||
.home b { color: var(--p-mid); font-weight: 400; }
|
.home b { color: var(--p-mid); font-weight: 400; }
|
||||||
/* Dasselbe Quadrat wie in der Menüleiste des Schreibtischs: dort ist es
|
/* Dasselbe Wappen wie in der Menüleiste des Schreibtischs, in derselben
|
||||||
klein und leicht angehoben. Ein Zeichen in voller Größe sah daneben
|
Größe. Es bleibt gelb, während der Rest die Röhre wechselt: Ein
|
||||||
wie ein anderes Logo aus. */
|
Zeichen, das die Farbe mitwechselt, ist ein Symbol — eines, das seine
|
||||||
.home .hex { font-size: 10px; vertical-align: 2px; }
|
Farbe behält, ist ein Wappen. */
|
||||||
|
.home .hex { display: inline-block; width: 20px; height: 20px; vertical-align: -6px; }
|
||||||
|
.home .hex svg { display: block; width: 100%; height: 100%; }
|
||||||
.home:hover { border-color: var(--p-dim); }
|
.home:hover { border-color: var(--p-dim); }
|
||||||
.home[aria-expanded="true"] { border-color: var(--p-dim); background: var(--p-faint); }
|
.home[aria-expanded="true"] { border-color: var(--p-dim); background: var(--p-faint); }
|
||||||
/* Bei vielen Apps schiebt man den Streifen seitwärts. Ohne
|
/* Bei vielen Apps schiebt man den Streifen seitwärts. Ohne
|
||||||
flex-shrink:0 an den Knöpfen quetscht das Raster sie stattdessen
|
flex-shrink:0 an den Knöpfen quetscht das Raster sie stattdessen
|
||||||
zusammen, bis nur noch Buchstabenreste dastehen — am Handy sofort.
|
zusammen, bis nur noch Buchstabenreste dastehen — am Handy sofort.
|
||||||
Der Schatten rechts zeigt an, dass es weitergeht. */
|
Der Schatten rechts zeigt an, dass es weitergeht. */
|
||||||
|
/* Nur der Streifen in der Leiste. Wer eine weitere Klasse "apps"
|
||||||
|
vergibt, erbt hier Maske und Bildlauf mit. */
|
||||||
.apps {
|
.apps {
|
||||||
display: flex; gap: 4px; flex: 1 1 auto; min-width: 0;
|
display: flex; gap: 4px; flex: 1 1 auto; min-width: 0;
|
||||||
overflow-x: auto; scrollbar-width: none;
|
overflow-x: auto; scrollbar-width: none;
|
||||||
@@ -188,15 +226,29 @@
|
|||||||
color: var(--p); text-shadow: var(--glow);
|
color: var(--p); text-shadow: var(--glow);
|
||||||
}
|
}
|
||||||
.menu[data-open="1"] { display: flex; }
|
.menu[data-open="1"] { display: flex; }
|
||||||
|
/* Die Listenpunkte des Systemmenues. Sie stehen VOR der Schublade:
|
||||||
|
Die Regel "menu a" ist spezifischer als die Klasse der Kachel, und
|
||||||
|
danach gewaehlt haetten die Kacheln Polster und Ausrichtung der Liste
|
||||||
|
geerbt statt ihrer eigenen. Genau das war passiert. */
|
||||||
|
.menu a, .menu button { text-align: left; padding: 6px 8px; color: var(--p-mid); }
|
||||||
|
.menu a:hover, .menu button:hover { background: var(--p-faint); color: var(--p); }
|
||||||
/* Die Schublade: Symbol, Name, Untertitel. Sie wird absichtlich
|
/* Die Schublade: Symbol, Name, Untertitel. Sie wird absichtlich
|
||||||
aufgemacht, weil man etwas sucht — also wird gelesen, nicht nur
|
aufgemacht, weil man etwas sucht — also wird gelesen, nicht nur
|
||||||
wiedererkannt. Deshalb stehen hier immer Namen, anders als im
|
wiedererkannt. Deshalb stehen hier immer Namen, anders als im
|
||||||
Streifen oben, wo ein Blick reichen muss. */
|
Streifen oben, wo ein Blick reichen muss. */
|
||||||
.menu.apps { min-width: 318px; max-width: min(360px, calc(100vw - 12px)); }
|
/* NICHT .menu.apps nennen: Der Streifen oben heisst .apps und blendet
|
||||||
|
seine rechten 18 px mit einer Maske aus, damit man sieht, dass es
|
||||||
|
seitwaerts weitergeht. Eine zweite Klasse "apps" am Menue holt diese
|
||||||
|
Maske mit — die Schublade sah rechts aus, als loese sie sich auf. */
|
||||||
|
.menu.schublade { min-width: 318px; max-width: min(360px, calc(100vw - 12px)); }
|
||||||
/* minmax(0, 1fr): Ohne das darf eine lange Beschreibung ihre Spalte
|
/* minmax(0, 1fr): Ohne das darf eine lange Beschreibung ihre Spalte
|
||||||
auseinanderdruecken, und die Schublade wird breiter als der Schirm. */
|
auseinanderdruecken, und die Schublade wird breiter als der Schirm. */
|
||||||
.drawer { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; }
|
.drawer { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; }
|
||||||
.d-app {
|
/* .menu .d-app, nicht .d-app: Die Kachel ist ein <a> im Menue, und
|
||||||
|
"menu a" oben ist spezifischer als eine einzelne Klasse — die
|
||||||
|
Kacheln trugen dadurch Polster und Ausrichtung der Listenpunkte.
|
||||||
|
Reihenfolge hilft dagegen nicht, nur Spezifitaet. */
|
||||||
|
.menu .d-app {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
display: flex; flex-direction: column; align-items: center; gap: 5px;
|
||||||
padding: 9px 4px; text-align: center; color: var(--p-mid);
|
padding: 9px 4px; text-align: center; color: var(--p-mid);
|
||||||
border: 1px solid transparent; min-width: 0;
|
border: 1px solid transparent; min-width: 0;
|
||||||
@@ -204,36 +256,44 @@
|
|||||||
/* Die Regel fuer a und button oben setzt nowrap — richtig fuer den
|
/* Die Regel fuer a und button oben setzt nowrap — richtig fuer den
|
||||||
Streifen, falsch
|
Streifen, falsch
|
||||||
hier: Der Untertitel soll umbrechen statt abgeschnitten zu werden. */
|
hier: Der Untertitel soll umbrechen statt abgeschnitten zu werden. */
|
||||||
.d-name, .d-sub { max-width: 100%; }
|
.menu .d-name, .menu .d-sub { max-width: 100%; }
|
||||||
.d-sub { white-space: normal; }
|
.menu .d-sub { white-space: normal; }
|
||||||
.d-name { overflow: hidden; text-overflow: ellipsis; }
|
.menu .d-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.d-app:hover { border-color: var(--p-dim); color: var(--p); }
|
.menu .d-app:hover { background: none; border-color: var(--p-dim); color: var(--p); }
|
||||||
.d-app[aria-current="page"] { border-color: var(--p); color: var(--p); }
|
.menu .d-app[aria-current="page"] { border-color: var(--p); color: var(--p); }
|
||||||
.d-app .ico { width: 24px; height: 24px; }
|
.menu .d-app .ico { width: 24px; height: 24px; }
|
||||||
.d-app .ico svg { width: 24px; height: 24px; }
|
.menu .d-app .ico svg { width: 24px; height: 24px; }
|
||||||
.d-name { letter-spacing: .08em; }
|
.menu .d-name { letter-spacing: .08em; }
|
||||||
/* Der Untertitel darf umbrechen, aber nicht wachsen — sonst stehen die
|
/* Der Untertitel darf umbrechen, aber nicht wachsen — sonst stehen die
|
||||||
Kacheln unterschiedlich hoch. */
|
Kacheln unterschiedlich hoch. */
|
||||||
.d-sub {
|
.menu .d-sub {
|
||||||
font-size: 10px; line-height: 1.25; color: var(--p-dim);
|
font-size: 10px; line-height: 1.25; color: var(--p-dim);
|
||||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.menu a, .menu button { text-align: left; padding: 6px 8px; color: var(--p-mid); }
|
|
||||||
.menu a:hover, .menu button:hover { background: var(--p-faint); color: var(--p); }
|
|
||||||
.menu hr { border: 0; border-top: 1px solid var(--p-faint); margin: 3px 0; }
|
.menu hr { border: 0; border-top: 1px solid var(--p-faint); margin: 3px 0; }
|
||||||
.menu .head {
|
.menu .head {
|
||||||
padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
|
padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
|
||||||
text-transform: uppercase; color: var(--p-dim);
|
text-transform: uppercase; color: var(--p-dim);
|
||||||
}
|
}
|
||||||
|
/* Ueberschrift einer Kategorie in der Schublade. Wie .head, nur mit
|
||||||
|
etwas Luft darueber, und sie spannt ueber alle drei Spalten der
|
||||||
|
Schublade — sonst stuende sie als vierte Kachel neben den Apps. */
|
||||||
|
.d-kopf {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 6px 4px 1px; font-size: 10px; letter-spacing: .18em;
|
||||||
|
text-transform: uppercase; color: var(--p-dim);
|
||||||
|
}
|
||||||
|
.d-kopf:first-child { padding-top: 0; }
|
||||||
/* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am
|
/* Dasselbe Menue, nur an der anderen Seite: das Systemmenue haengt am
|
||||||
Logo links, das App-Menue am Pfeil rechts. */
|
Logo links, das App-Menue am Pfeil rechts. */
|
||||||
.menu.sys { left: 6px; right: auto; }
|
.menu.sys { left: 6px; right: auto; }
|
||||||
@media (max-width: 560px) { .clock { display: none; } }
|
@media (max-width: 560px) { .clock { display: none; } }
|
||||||
</style>
|
`;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button class="home" id="home" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false"><span class="hex">■</span> HONEY<b>OS</b></button>
|
<button class="home" id="home" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false"><span class="hex">${WAPPEN}</span> HONEY<b>OS</b></button>
|
||||||
<nav class="apps" id="apps"></nav>
|
<nav class="apps" id="apps"></nav>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<span class="wx" id="wx" hidden></span>
|
<span class="wx" id="wx" hidden></span>
|
||||||
@@ -245,16 +305,17 @@
|
|||||||
<div class="menu sys" id="sysmenu" role="menu">
|
<div class="menu sys" id="sysmenu" role="menu">
|
||||||
<div class="head">honeyos</div>
|
<div class="head">honeyos</div>
|
||||||
<a id="mHome" role="menuitem">Schreibtisch</a>
|
<a id="mHome" role="menuitem">Schreibtisch</a>
|
||||||
<a id="mSettings" role="menuitem">Einstellungen …</a>
|
|
||||||
<hr />
|
<hr />
|
||||||
<button id="hide" role="menuitem">HoneyBar einklappen</button>
|
<button id="hide" role="menuitem">HoneyBar einklappen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu apps" id="menu" role="menu">
|
<div class="menu schublade" id="menu" role="menu">
|
||||||
<div class="head">wechseln zu</div>
|
<div class="head">wechseln zu</div>
|
||||||
<div class="drawer" id="menuApps"></div>
|
<div class="drawer" id="menuApps"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
stileAnlegen(root, STIL);
|
||||||
|
|
||||||
/* ── Aussehen ─────────────────────────────────────────────────────────────
|
/* ── Aussehen ─────────────────────────────────────────────────────────────
|
||||||
* Dieselben drei Röhren wie in style.css. Sie hier zu wiederholen ist die
|
* Dieselben drei Röhren wie in style.css. Sie hier zu wiederholen ist die
|
||||||
* bewusste Entscheidung: die HoneyBar soll ohne zweite Datei auskommen, damit
|
* bewusste Entscheidung: die HoneyBar soll ohne zweite Datei auskommen, damit
|
||||||
@@ -303,6 +364,13 @@
|
|||||||
return span;
|
return span;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Die Schublade bekommt Ueberschriften, der Streifen nicht: Dort sucht man
|
||||||
|
// eine App, hier erkennt man sie im Vorbeigehen wieder — und fuer eine
|
||||||
|
// Ueberschrift ist in 32 Pixeln Hoehe ohnehin kein Platz. Die Reihenfolge
|
||||||
|
// der Gruppen ist die ihres ersten Auftretens in der Liste; HoneyOS
|
||||||
|
// sortiert sie schon so, hier wird nur eingesammelt.
|
||||||
|
let letzteKategorie = null;
|
||||||
|
|
||||||
apps.forEach((app) => {
|
apps.forEach((app) => {
|
||||||
if (app.id === "honeyos") return; // steckt schon links im Logo
|
if (app.id === "honeyos") return; // steckt schon links im Logo
|
||||||
|
|
||||||
@@ -317,6 +385,17 @@
|
|||||||
if (app.id === current) a.setAttribute("aria-current", "page");
|
if (app.id === current) a.setAttribute("aria-current", "page");
|
||||||
nav.appendChild(a);
|
nav.appendChild(a);
|
||||||
|
|
||||||
|
const kat = (app.kategorie || "").trim();
|
||||||
|
if (kat !== letzteKategorie) {
|
||||||
|
letzteKategorie = kat;
|
||||||
|
if (kat) {
|
||||||
|
const kopf = document.createElement("div");
|
||||||
|
kopf.className = "d-kopf";
|
||||||
|
kopf.textContent = kat;
|
||||||
|
menuApps.appendChild(kopf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const m = document.createElement("a");
|
const m = document.createElement("a");
|
||||||
m.className = "d-app";
|
m.className = "d-app";
|
||||||
m.href = app.url;
|
m.href = app.url;
|
||||||
@@ -455,25 +534,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die zwei Wege ins System. Beides sind Links auf den Schreibtisch, keine
|
* Der eine Weg ins System: ein Link auf den Schreibtisch, keine Aktion hier.
|
||||||
* Aktionen hier — und das ist keine Bequemlichkeit, sondern die Grenze:
|
|
||||||
*
|
*
|
||||||
* Einstellungen **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung,
|
* Das ist keine Bequemlichkeit, sondern die Grenze: Einstellungen
|
||||||
* und die schickt der Browser aus einer fremden App nicht mit; liegt alles
|
* **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung, und die
|
||||||
* unter einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in
|
* schickt der Browser aus einer fremden App nicht mit; liegt alles unter
|
||||||
* einem Cookie der HoneyOS-Adresse, das dieses Skript von hier aus nicht
|
* einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in einem
|
||||||
* lesen darf.
|
* Cookie der HoneyOS-Adresse, das dieses Skript von hier aus nicht lesen darf.
|
||||||
|
*
|
||||||
|
* Hier stand deshalb lange ein zweiter Punkt „Einstellungen …", der auf
|
||||||
|
* dieselbe Seite fuehrte, nur mit geoeffnetem Feld. **Weg damit** (Marcs
|
||||||
|
* Einwand, 01.09.2026): Zwei Tueren in denselben Raum sind keine zwei Wege.
|
||||||
|
* Wer eine Einstellung aendern will, ist ohnehin auf dem Schreibtisch, und
|
||||||
|
* dort steht sie einen Klick oder ein [S] entfernt.
|
||||||
*
|
*
|
||||||
* Abmelden steht hier bewusst gar nicht: Es gehoert dorthin, wo man sich
|
* Abmelden steht hier bewusst gar nicht: Es gehoert dorthin, wo man sich
|
||||||
* angemeldet hat. Wer es aus einer App heraus braucht, geht ueber
|
* angemeldet hat — unten in den Einstellungen.
|
||||||
* „Einstellungen …" — dort unten steht der Knopf.
|
|
||||||
*/
|
*/
|
||||||
function systemZiele() {
|
function systemZiele() {
|
||||||
const heim = new URL(homeUrl, location.href);
|
root.getElementById("mHome").href = new URL(homeUrl, location.href).href;
|
||||||
root.getElementById("mHome").href = heim.href;
|
|
||||||
const einst = new URL(homeUrl, location.href);
|
|
||||||
einst.search = "einstellungen=1";
|
|
||||||
root.getElementById("mSettings").href = einst.href;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -498,8 +577,11 @@
|
|||||||
// auch wenn in HoneyOS Grün eingestellt war. Er gehört zur HoneyBar, also
|
// auch wenn in HoneyOS Grün eingestellt war. Er gehört zur HoneyBar, also
|
||||||
// trägt er dieselbe Röhre.
|
// trägt er dieselbe Röhre.
|
||||||
const t = ROEHREN[(settings && settings.phosphor) || "amber"] || ROEHREN.amber;
|
const t = ROEHREN[(settings && settings.phosphor) || "amber"] || ROEHREN.amber;
|
||||||
r.innerHTML = `
|
// Auch hier ueber die CSSOM statt als style-Element: Der Strich hat seinen
|
||||||
<style>
|
// eigenen Shadow-Root und waere sonst das einzige Stueck geblieben, das
|
||||||
|
// unter einer strengen CSP unsichtbar bleibt.
|
||||||
|
r.innerHTML = `<div class="strich"></div>`;
|
||||||
|
stileAnlegen(r, `
|
||||||
.strich {
|
.strich {
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: linear-gradient(90deg, ${t.dim}, ${t.p} 50%, ${t.dim});
|
background: linear-gradient(90deg, ${t.dim}, ${t.p} 50%, ${t.dim});
|
||||||
@@ -507,8 +589,7 @@
|
|||||||
transition: opacity .15s, height .15s;
|
transition: opacity .15s, height .15s;
|
||||||
}
|
}
|
||||||
:host(:hover) .strich { opacity: 1; height: 3px; }
|
:host(:hover) .strich { opacity: 1; height: 3px; }
|
||||||
</style>
|
`);
|
||||||
<div class="strich"></div>`;
|
|
||||||
|
|
||||||
nub.addEventListener("click", () => {
|
nub.addEventListener("click", () => {
|
||||||
forget();
|
forget();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
+12
-31
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0a0705" />
|
<meta name="theme-color" content="#0a0705" />
|
||||||
<title>HoneyOS</title>
|
<title>HoneyOS</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230a0705'/%3E%3Cpath d='M4 4h2v3h4V4h2v8h-2V9H6v3H4z' fill='%23ffb641'/%3E%3C/svg%3E" />
|
<link rel="icon" href="./img/biene.svg" />
|
||||||
<link rel="stylesheet" href="./css/style.css" />
|
<link rel="stylesheet" href="./css/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<div class="crt" id="screen">
|
<div class="crt" id="screen">
|
||||||
<!-- ── Boot ───────────────────────────────────────────────────────── -->
|
<!-- ── Boot ───────────────────────────────────────────────────────── -->
|
||||||
<div class="boot" id="boot" hidden>
|
<div class="boot" id="boot" hidden>
|
||||||
|
<pre class="biene-text boot-biene" id="bootBiene" aria-hidden="true"></pre>
|
||||||
<pre class="boot-log" id="bootLog" aria-live="polite"></pre>
|
<pre class="boot-log" id="bootLog" aria-live="polite"></pre>
|
||||||
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
|
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,6 +21,9 @@
|
|||||||
<!-- ── Anmeldung ──────────────────────────────────────────────────── -->
|
<!-- ── Anmeldung ──────────────────────────────────────────────────── -->
|
||||||
<div class="login" id="login" hidden>
|
<div class="login" id="login" hidden>
|
||||||
<pre class="boot-log" id="loginLog"></pre>
|
<pre class="boot-log" id="loginLog"></pre>
|
||||||
|
<!-- Dasselbe Wappen wie oben links in der Leiste und im Tab, nur groß:
|
||||||
|
ein Gerät, ein Zeichen. -->
|
||||||
|
<img class="login-biene" src="./img/biene.svg" alt="" width="160" height="160" />
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<button class="login-btn" id="btnLogin">▸ <span id="loginLabel">Anmelden</span></button>
|
<button class="login-btn" id="btnLogin">▸ <span id="loginLabel">Anmelden</span></button>
|
||||||
<p class="login-note" id="loginNote"></p>
|
<p class="login-note" id="loginNote"></p>
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
<!-- Das Logo ist das Systemmenü — dasselbe wie in der HoneyBar
|
<!-- Das Logo ist das Systemmenü — dasselbe wie in der HoneyBar
|
||||||
fremder Apps, damit HoneyOS von überall gleich erreichbar ist. -->
|
fremder Apps, damit HoneyOS von überall gleich erreichbar ist. -->
|
||||||
<button class="mb-logo" id="btnSystem" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false">
|
<button class="mb-logo" id="btnSystem" title="HoneyOS-Menü" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="hex">■</span> HONEY<b>OS</b>
|
<span class="hex" id="logoBiene"></span> HONEY<b>OS</b>
|
||||||
</button>
|
</button>
|
||||||
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
|
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
|
||||||
<div class="mb-right">
|
<div class="mb-right">
|
||||||
@@ -47,30 +51,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="stage" id="stage">
|
<main class="stage" id="stage">
|
||||||
<!-- Statusfenster -->
|
<!-- Links die Widgets untereinander, rechts die Apps. Was hier steht,
|
||||||
<section class="win win-status">
|
entscheidet der Widget-Rahmen (js/widgets/rahmen.js) — im
|
||||||
<div class="win-bar"><span class="win-title">system</span><span class="win-dots">●●●</span></div>
|
Dokument steht nur der Platz dafür. -->
|
||||||
<div class="win-body">
|
<div class="widgetspalte" id="widgetSpalte"></div>
|
||||||
<dl class="statlist" id="statList"></dl>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Wer angemeldet ist. Stand früher in der Menüleiste — dort ist
|
|
||||||
es aber der einzige Unterschied zur HoneyBar in den Apps
|
|
||||||
gewesen, und der Schreibtisch hat für so etwas Platz. -->
|
|
||||||
<section class="win win-user" id="userTile" hidden>
|
|
||||||
<div class="win-bar"><span class="win-title">angemeldet</span><span class="win-dots">●●●</span></div>
|
|
||||||
<div class="win-body user-body"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Wetter. Steht in beiden Betriebsarten zur Verfügung, wird aber
|
|
||||||
nur gezeigt, wenn ein Ort eingestellt und das Widget an ist. -->
|
|
||||||
<section class="win win-weather" id="wxTile" hidden>
|
|
||||||
<div class="win-bar"><span class="win-title">wetter</span><span class="win-dots">●●●</span></div>
|
|
||||||
<div class="win-body wx-body"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- App-Kacheln -->
|
|
||||||
<section class="tiles" id="tiles" aria-label="Apps starten"></section>
|
<section class="tiles" id="tiles" aria-label="Apps starten"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -78,11 +62,8 @@
|
|||||||
<div class="deskspace" id="deskspace" hidden>
|
<div class="deskspace" id="deskspace" hidden>
|
||||||
<div class="desk-icons" id="deskIcons"></div>
|
<div class="desk-icons" id="deskIcons"></div>
|
||||||
<div class="windows" id="windows"></div>
|
<div class="windows" id="windows"></div>
|
||||||
<!-- Dasselbe Wetter, im Schreibtisch-Modus als kleines Feld oben rechts -->
|
<!-- Dieselben Widgets, im Schreibtisch-Modus als Schilder oben rechts -->
|
||||||
<div class="deskwidgets">
|
<div class="deskwidgets" id="deskwidgets"></div>
|
||||||
<div class="deskwidget" id="userDesk" hidden></div>
|
|
||||||
<div class="deskwidget" id="wxDesk" hidden></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="taskbar" id="taskbar" hidden>
|
<footer class="taskbar" id="taskbar" hidden>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* Erzeugt von tools/biene.py — nicht von Hand ändern.
|
||||||
|
*
|
||||||
|
* Das Wappen als Markup statt als Bilddatei: So kommt es ohne zweite
|
||||||
|
* Anfrage aus und lässt sich mit dem Text daneben ausrichten.
|
||||||
|
*/
|
||||||
|
export const BIENE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 12" shape-rendering="crispEdges" aria-hidden="true"><rect x="4" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="9" y="0" width="2" height="1" fill="#f5f2e7"/><rect x="2" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="8" y="1" width="5" height="1" fill="#f5f2e7"/><rect x="2" y="2" width="11" height="1" fill="#f5f2e7"/><rect x="14" y="2" width="1" height="1" fill="#3b2412"/><rect x="3" y="3" width="9" height="1" fill="#f5f2e7"/><rect x="13" y="3" width="2" height="1" fill="#3b2412"/><rect x="3" y="4" width="11" height="1" fill="#ffd060"/><rect x="2" y="5" width="2" height="1" fill="#f2b33d"/><rect x="4" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="5" width="2" height="1" fill="#f2b33d"/><rect x="8" y="5" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="5" width="5" height="1" fill="#f2b33d"/><rect x="1" y="6" width="3" height="1" fill="#f2b33d"/><rect x="4" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="6" width="2" height="1" fill="#f2b33d"/><rect x="8" y="6" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="6" width="2" height="1" fill="#f2b33d"/><rect x="12" y="6" width="1" height="1" fill="#181410"/><rect x="13" y="6" width="1" height="1" fill="#63c7c0"/><rect x="14" y="6" width="1" height="1" fill="#f2b33d"/><rect x="0" y="7" width="1" height="1" fill="#5c3a1c"/><rect x="1" y="7" width="3" height="1" fill="#f2b33d"/><rect x="4" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="7" width="2" height="1" fill="#f2b33d"/><rect x="8" y="7" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="7" width="2" height="1" fill="#f2b33d"/><rect x="12" y="7" width="2" height="1" fill="#181410"/><rect x="14" y="7" width="1" height="1" fill="#f2b33d"/><rect x="1" y="8" width="3" height="1" fill="#f2b33d"/><rect x="4" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="6" y="8" width="2" height="1" fill="#f2b33d"/><rect x="8" y="8" width="2" height="1" fill="#5c3a1c"/><rect x="10" y="8" width="5" height="1" fill="#f2b33d"/><rect x="2" y="9" width="1" height="1" fill="#d2952a"/><rect x="3" y="9" width="2" height="1" fill="#462b14"/><rect x="5" y="9" width="2" height="1" fill="#d2952a"/><rect x="7" y="9" width="2" height="1" fill="#462b14"/><rect x="9" y="9" width="6" height="1" fill="#d2952a"/><rect x="3" y="10" width="2" height="1" fill="#181410"/><rect x="7" y="10" width="2" height="1" fill="#181410"/><rect x="11" y="10" width="2" height="1" fill="#181410"/><rect x="3" y="11" width="2" height="1" fill="#181410"/><rect x="7" y="11" width="2" height="1" fill="#181410"/><rect x="11" y="11" width="2" height="1" fill="#181410"/></svg>';
|
||||||
|
|
||||||
|
export const BIENE_ASCII = " ---- ----\n ---------- ----------\n ---------------------- ::\n ------------------ ::::\n ######################\n ####::::####::::##########\n ######::::####::::####::oo##\n::######::::####::::####::::##\n ######::::####::::##########\n ##::::####::::############\n :::: :::: ::::\n :::: :::: ::::";
|
||||||
+38
-5
@@ -16,14 +16,15 @@ export const DEFAULT_SETTINGS = {
|
|||||||
openIn: "tab", // tab | same
|
openIn: "tab", // tab | same
|
||||||
// Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer.
|
// Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer.
|
||||||
barGlyphs: "on",
|
barGlyphs: "on",
|
||||||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
|
// Wetter in der HoneyBar. Auf dem Schreibtisch entscheidet das die Widget-
|
||||||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
// Liste — zwei Schalter für dasselbe Widget wären einer zu viel.
|
||||||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
|
||||||
weatherBar: "off",
|
weatherBar: "off",
|
||||||
weatherDesk: "off",
|
|
||||||
weatherCity: "",
|
weatherCity: "",
|
||||||
weatherLat: "",
|
weatherLat: "",
|
||||||
weatherLon: "",
|
weatherLon: "",
|
||||||
|
// Reihenfolge und An/Aus der Widgets, als eine Liste: "benutzer,-wetter,notiz".
|
||||||
|
// Leer heißt: Grundstellung (siehe widgets/rahmen.js).
|
||||||
|
widgets: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -53,7 +54,9 @@ export async function api(path, opts = {}) {
|
|||||||
const headers = { ...(opts.headers || {}) };
|
const headers = { ...(opts.headers || {}) };
|
||||||
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||||
if (opts.method && opts.method !== "GET") {
|
if (opts.method && opts.method !== "GET") {
|
||||||
headers["Content-Type"] = "application/json";
|
// Nur voreinstellen, nicht überschreiben: Das Bild-Widget schickt Bytes und
|
||||||
|
// nennt deren Typ selbst. Vorher log der Aufrufhelfer den Server an.
|
||||||
|
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
||||||
const tok = cookie("honeyos_csrf");
|
const tok = cookie("honeyos_csrf");
|
||||||
if (tok) headers["X-CSRF-Token"] = tok;
|
if (tok) headers["X-CSRF-Token"] = tok;
|
||||||
}
|
}
|
||||||
@@ -120,3 +123,33 @@ export function applySettings() {
|
|||||||
r.dataset.glow = state.settings.glow;
|
r.dataset.glow = state.settings.glow;
|
||||||
r.dataset.mode = state.settings.mode;
|
r.dataset.mode = state.settings.mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Kategorien ────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Apps tragen eine Kategorie als freien Text. Eine Liste von Kategorien gibt
|
||||||
|
* es nicht: Eine Kategorie besteht, solange eine App sie trägt. Deshalb ist
|
||||||
|
* dies auch keine Sortierung, sondern nur ein Einsammeln — die Reihenfolge
|
||||||
|
* der Gruppen ist die ihres ersten Auftretens, die Reihenfolge innerhalb
|
||||||
|
* einer Gruppe die der App-Liste. Damit ordnen dieselben Pfeile wie bisher
|
||||||
|
* beides zugleich, und es gibt nichts, was auseinanderlaufen könnte.
|
||||||
|
*
|
||||||
|
* Apps ohne Kategorie bilden die erste Gruppe, und die trägt keine
|
||||||
|
* Überschrift: Wer nie eine Kategorie vergibt, sieht alles wie zuvor.
|
||||||
|
*/
|
||||||
|
export function gruppiere(apps) {
|
||||||
|
const gruppen = [];
|
||||||
|
const nach = new Map();
|
||||||
|
for (const app of apps) {
|
||||||
|
const k = (app.kategorie || "").trim();
|
||||||
|
if (!nach.has(k)) {
|
||||||
|
const g = { kategorie: k, apps: [] };
|
||||||
|
nach.set(k, g);
|
||||||
|
gruppen.push(g);
|
||||||
|
}
|
||||||
|
nach.get(k).apps.push(app);
|
||||||
|
}
|
||||||
|
// Ohne Kategorie zuerst — das ist der Stand von vorher, und der gehört nach
|
||||||
|
// oben, nicht dahin, wo zufällig die erste unbenannte App steht.
|
||||||
|
gruppen.sort((a, b) => (a.kategorie === "" ? -1 : b.kategorie === "" ? 1 : 0));
|
||||||
|
return gruppen;
|
||||||
|
}
|
||||||
+284
-112
@@ -1,11 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, isTouch,
|
$, api, APPS_KEY, cookie, DEFAULT_SETTINGS, escapeAttr, escapeHtml, gruppiere, isTouch,
|
||||||
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
|
applySettings, loadLocalSettings, LS_SETTINGS, readJson, saveSettings,
|
||||||
SS_BOOTED, state,
|
SS_BOOTED, state,
|
||||||
} from "./core.js";
|
} from "./core.js";
|
||||||
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
||||||
import { renderUser } from "./widgets/user.js";
|
import { BIENE_ASCII, BIENE_SVG } from "./biene.js";
|
||||||
import { loadWeather, renderWeather } from "./widgets/weather.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.
|
* HoneyOS — Desktop-Logik.
|
||||||
@@ -67,6 +80,9 @@ function runBoot() {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const log = $("bootLog");
|
const log = $("bootLog");
|
||||||
const el = $("boot");
|
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;
|
el.hidden = false;
|
||||||
const lines = bootLines();
|
const lines = bootLines();
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -110,14 +126,20 @@ function runBoot() {
|
|||||||
|
|
||||||
function renderShell() {
|
function renderShell() {
|
||||||
$("desktop").hidden = false;
|
$("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();
|
renderMenubarApps();
|
||||||
renderMode();
|
renderMode();
|
||||||
tickClock();
|
tickClock();
|
||||||
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
|
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
|
||||||
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
|
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
|
||||||
// in dem der Server seinen Zwischenspeicher erneuert.
|
// in dem der Server seinen Zwischenspeicher erneuert.
|
||||||
loadWeather();
|
// Nicht abgewartet: Die Widgets holen ihre Daten von außen und dürfen den
|
||||||
setInterval(loadWeather, 10 * 60 * 1000);
|
// Aufbau des Schreibtischs nicht verzögern. Den Takt fürs Nachladen kennt
|
||||||
|
// jedes Widget selbst.
|
||||||
|
widgetsLaden().then(renderWeatherBar);
|
||||||
|
widgetsStarten();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMenubarApps() {
|
function renderMenubarApps() {
|
||||||
@@ -163,9 +185,7 @@ function renderMode() {
|
|||||||
closeAllWindows();
|
closeAllWindows();
|
||||||
renderTiles();
|
renderTiles();
|
||||||
}
|
}
|
||||||
renderStats();
|
widgetsZeichnen();
|
||||||
renderUser();
|
|
||||||
renderWeather();
|
|
||||||
paintProbes();
|
paintProbes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,18 +208,13 @@ function launch(app) {
|
|||||||
|
|
||||||
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
|
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
|
||||||
* alle vorhandenen Listen schreiben, nicht in eine bestimmte id. */
|
* 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() {
|
function renderStats() {
|
||||||
const up = Object.values(state.probes).filter((s) => s === "up").length;
|
widgetsZeichnen();
|
||||||
const rows = [
|
document.querySelectorAll("#windows .statlist").forEach((dl) => {
|
||||||
["apps", `${state.cfg.apps.length}`],
|
dl.outerHTML = statsHtml();
|
||||||
["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() {
|
function tickClock() {
|
||||||
@@ -270,7 +285,7 @@ function renderTiles() {
|
|||||||
const wrap = $("tiles");
|
const wrap = $("tiles");
|
||||||
wrap.innerHTML = "";
|
wrap.innerHTML = "";
|
||||||
|
|
||||||
state.cfg.apps.forEach((app) => {
|
const kachel = (app) => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = "tile";
|
a.className = "tile";
|
||||||
a.href = app.url;
|
a.href = app.url;
|
||||||
@@ -285,7 +300,25 @@ function renderTiles() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
launch(app);
|
launch(app);
|
||||||
});
|
});
|
||||||
wrap.appendChild(a);
|
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");
|
const add = document.createElement("button");
|
||||||
@@ -490,13 +523,18 @@ function openSystemWindow() {
|
|||||||
id: "system",
|
id: "system",
|
||||||
title: "system",
|
title: "system",
|
||||||
w: 320,
|
w: 320,
|
||||||
body: `<p class="greet"></p><dl class="statlist"></dl>`,
|
// Derselbe Inhalt wie das Widget im Terminal-Modus — eine Quelle, damit
|
||||||
onMount: renderStats,
|
// nicht zwei Listen mit denselben Zahlen auseinanderlaufen.
|
||||||
|
body: statsHtml(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProgramsWindow() {
|
function openProgramsWindow() {
|
||||||
const rows = state.cfg.apps
|
const rows = gruppiere(state.cfg.apps)
|
||||||
|
.map(
|
||||||
|
(g) =>
|
||||||
|
(g.kategorie ? `<div class="al-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
|
||||||
|
g.apps
|
||||||
.map(
|
.map(
|
||||||
(a) => `<button data-launch="${a.id}">
|
(a) => `<button data-launch="${a.id}">
|
||||||
<span class="al-glyph">${glyphSvg(a.glyph)}</span>
|
<span class="al-glyph">${glyphSvg(a.glyph)}</span>
|
||||||
@@ -504,6 +542,8 @@ function openProgramsWindow() {
|
|||||||
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
|
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
|
||||||
</button>`
|
</button>`
|
||||||
)
|
)
|
||||||
|
.join("")
|
||||||
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
openWindow({
|
openWindow({
|
||||||
@@ -544,75 +584,72 @@ function closeModal() {
|
|||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
const s = state.settings;
|
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) =>
|
const seg = (name, opts, cur) =>
|
||||||
`<div class="seg" data-seg="${name}">` +
|
`<div class="seg" data-seg="${name}">` +
|
||||||
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
|
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
|
||||||
`</div>`;
|
`</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(
|
openPanel(
|
||||||
"settings",
|
"settings",
|
||||||
"einstellungen",
|
"einstellungen",
|
||||||
`
|
sektion(
|
||||||
<div class="field">
|
"Darstellung",
|
||||||
<span class="label">Betriebsart</span>
|
zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
|
||||||
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
|
zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
|
||||||
</div>
|
schalter("scanlines", "Scanlines") +
|
||||||
|
schalter("flicker", "Flackern") +
|
||||||
<div class="field">
|
schalter("glow", "Glühen")
|
||||||
<span class="label">Röhre</span>
|
) +
|
||||||
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
sektion(
|
||||||
</div>
|
"HoneyBar",
|
||||||
|
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
|
||||||
<div class="field">
|
zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
|
||||||
<span class="label">Bildröhren-Effekte</span>
|
schalter("weatherBar", "Wetter in der Leiste") +
|
||||||
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
|
`<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
|
||||||
<div style="height:6px"></div>
|
) +
|
||||||
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
|
sektion(
|
||||||
<div style="height:6px"></div>
|
"Widgets",
|
||||||
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
|
`<ul class="wl" id="wlListe"></ul>
|
||||||
</div>
|
<p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
|
||||||
|
) +
|
||||||
<div class="field">
|
sektion(
|
||||||
<span class="label">HoneyBar</span>
|
"Bild",
|
||||||
${seg("barGlyphs", [["on", "symbole an"], ["off", "nur namen"]], s.barGlyphs)}
|
bildFormularHtml() +
|
||||||
<p class="ae-hint" style="margin-left:0">gilt für den App-Streifen hier und in jeder App</p>
|
`<p class="sek-hinweis">Genau ein Bild je Konto; ein neues ersetzt das alte.
|
||||||
</div>
|
Zu sehen ist es im Widget „bild".</p>`
|
||||||
|
) +
|
||||||
<div class="field">
|
sektion(
|
||||||
<span class="label">Apps öffnen</span>
|
"Wetter",
|
||||||
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
|
`<div class="wx-city-row">
|
||||||
</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" />
|
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
|
||||||
<button class="btn" id="btnWxSearch">suchen</button>
|
<button class="btn" id="btnWxSearch">suchen</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="wxResults" class="wx-results"></div>
|
<div id="wxResults" class="wx-results"></div>
|
||||||
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p>
|
<p class="sek-hinweis" id="wxCurrent"></p>`
|
||||||
</div>
|
) +
|
||||||
|
sektion(
|
||||||
<div class="field">
|
"Programme",
|
||||||
<span class="label">Programme</span>
|
zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
|
||||||
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
) +
|
||||||
</div>
|
sektion(
|
||||||
|
"Konto",
|
||||||
<p class="login-note" style="margin-top:4px">
|
`<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||||
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
|
||||||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
|
||||||
</p>` +
|
// einer fremden App heraus nicht anklicken können soll (siehe
|
||||||
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus einer
|
// honeybar.js), und von HoneyOS aus findet man es dort, wo alles
|
||||||
// fremden App heraus nicht anklicken können soll (siehe honeybar.js), und
|
// andere auch steht.
|
||||||
// von HoneyOS aus findet man es dort, wo alles andere auch steht.
|
(state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
|
||||||
(state.oidc
|
),
|
||||||
? `<div class="field field-out">
|
|
||||||
<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>
|
|
||||||
</div>`
|
|
||||||
: ""),
|
|
||||||
(root) => {
|
(root) => {
|
||||||
root.querySelectorAll(".seg").forEach((segEl) => {
|
root.querySelectorAll(".seg").forEach((segEl) => {
|
||||||
segEl.addEventListener("click", (e) => {
|
segEl.addEventListener("click", (e) => {
|
||||||
@@ -637,6 +674,9 @@ function openSettings() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
widgetListeZeichnen(root.querySelector("#wlListe"));
|
||||||
|
bildFormularMontieren(root);
|
||||||
|
|
||||||
const zeigeOrt = () => {
|
const zeigeOrt = () => {
|
||||||
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
|
root.querySelector("#wxCurrent").textContent = state.settings.weatherCity
|
||||||
? `eingestellt: ${state.settings.weatherCity}`
|
? `eingestellt: ${state.settings.weatherCity}`
|
||||||
@@ -691,13 +731,108 @@ function openSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 ────────────────────────────────────────────────────────
|
/* ── App-Verwaltung ────────────────────────────────────────────────────────
|
||||||
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
|
* 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
|
* 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.
|
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
|
||||||
|
*//* Bearbeitet wird eine Liste aus zweierlei Zeilen: Apps und Überschriften.
|
||||||
|
*
|
||||||
|
* Das ist bewusst **nicht** das, was gespeichert wird — gespeichert ist eine
|
||||||
|
* Kategorie als Feld an jeder App. Die Überschrift gibt es nur hier, weil sie
|
||||||
|
* hier das ehrlichere Bild ist: Man sieht, was man sortiert, und das Umhängen
|
||||||
|
* einer App braucht keine Sonderregel, es ergibt sich daraus, dass sie unter
|
||||||
|
* einer anderen Überschrift landet.
|
||||||
|
*
|
||||||
|
* In der App-Liste selbst hätten Überschriften nichts zu suchen: Dann enthielte
|
||||||
|
* sie Einträge ohne ID und ohne Adresse, und jeder Leser müsste das wissen —
|
||||||
|
* die Kachelfläche, die Schublade, die Erreichbarkeitsprüfung und vor allem
|
||||||
|
* die HoneyBar in fremden Apps, die mit so wenig wie möglich auskommen soll.
|
||||||
|
*
|
||||||
|
* Was oberhalb der ersten Überschrift steht, hat keine Kategorie.
|
||||||
*/
|
*/
|
||||||
function openApps() {
|
function openApps() {
|
||||||
let draft = state.cfg.apps.map((a) => ({ ...a }));
|
const KOPF = "kopf";
|
||||||
|
|
||||||
|
// Aus der gespeicherten Liste die Zeilen bauen: je Gruppe eine Überschrift,
|
||||||
|
// darunter ihre Apps. Die Gruppe ohne Kategorie steht oben und bekommt keine.
|
||||||
|
const zeilenAus = (apps) =>
|
||||||
|
gruppiere(apps).flatMap((g) => [
|
||||||
|
...(g.kategorie ? [{ typ: KOPF, name: g.kategorie }] : []),
|
||||||
|
...g.apps.map((a) => ({ ...a })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Und zurück: durchlaufen, die zuletzt gesehene Überschrift mitschreiben.
|
||||||
|
// Eine Überschrift ohne Apps darunter verschwindet dabei — eine Kategorie
|
||||||
|
// besteht nur durch die Apps, die sie tragen.
|
||||||
|
const appsAus = (zeilen) => {
|
||||||
|
let kategorie = "";
|
||||||
|
const out = [];
|
||||||
|
for (const z of zeilen) {
|
||||||
|
if (z.typ === KOPF) {
|
||||||
|
kategorie = z.name.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { __offen, ...rest } = z;
|
||||||
|
out.push({ ...rest, kategorie });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
let zeilen = zeilenAus(state.cfg.apps);
|
||||||
|
|
||||||
// Die Auswahl kommt aus glyphs.json selbst — wer dort ein Symbol ergänzt,
|
// 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
|
// kann es sofort wählen. Die Wettersymbole liegen in derselben Datei (die
|
||||||
@@ -705,18 +840,30 @@ function openApps() {
|
|||||||
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
|
const WETTER_GLYPHEN = ["sonne", "heiter", "wolke", "nebel", "regen", "schnee", "gewitter"];
|
||||||
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
|
const GLYPHS_LIST = Object.keys(GLYPHS).filter((g) => !WETTER_GLYPHEN.includes(g));
|
||||||
|
|
||||||
|
const pfeile = (i, was) => `
|
||||||
|
<span class="ae-move">
|
||||||
|
<button class="ae-arrow" data-up="${i}" ${i === 0 ? "disabled" : ""}
|
||||||
|
title="nach oben" aria-label="${escapeAttr(was)} nach oben">▲</button>
|
||||||
|
<button class="ae-arrow" data-down="${i}" ${i === zeilen.length - 1 ? "disabled" : ""}
|
||||||
|
title="nach unten" aria-label="${escapeAttr(was)} nach unten">▼</button>
|
||||||
|
</span>`;
|
||||||
|
|
||||||
|
const kopfHtml = (z, i) => `
|
||||||
|
<div class="ae-gruppe">
|
||||||
|
<input class="ae-kat" data-k="${i}" value="${escapeAttr(z.name)}"
|
||||||
|
placeholder="kategorie" aria-label="Name der Kategorie" />
|
||||||
|
<button class="ae-kat-weg" data-kdel="${i}" title="Kategorie auflösen"
|
||||||
|
aria-label="Kategorie ${escapeAttr(z.name)} auflösen">×</button>
|
||||||
|
${pfeile(i, z.name || "kategorie")}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
const rowHtml = (a, i) => `
|
const rowHtml = (a, i) => `
|
||||||
<details class="approw" ${a.__offen ? "open" : ""}>
|
<details class="approw" ${a.__offen ? "open" : ""}>
|
||||||
<summary>
|
<summary>
|
||||||
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
|
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
|
||||||
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
|
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
|
||||||
<span class="ae-url">${escapeHtml(a.url || "")}</span>
|
<span class="ae-url">${escapeHtml(a.url || "")}</span>
|
||||||
<span class="ae-move">
|
${pfeile(i, a.name || a.id)}
|
||||||
<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>
|
</summary>
|
||||||
<div class="ae-body">
|
<div class="ae-body">
|
||||||
<label class="ae-field"><span>ID</span>
|
<label class="ae-field"><span>ID</span>
|
||||||
@@ -740,35 +887,55 @@ function openApps() {
|
|||||||
|
|
||||||
const render = (root) => {
|
const render = (root) => {
|
||||||
const list = root.querySelector("#appList");
|
const list = root.querySelector("#appList");
|
||||||
list.innerHTML = draft.length
|
list.innerHTML = zeilen.length
|
||||||
? draft.map(rowHtml).join("")
|
? zeilen.map((z, i) => (z.typ === KOPF ? kopfHtml(z, i) : rowHtml(z, i))).join("")
|
||||||
: `<div class="apped-empty">Noch keine App eingetragen.<br />Mit „+ neue app" die erste anlegen.</div>`;
|
: `<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) => {
|
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
|
||||||
inp.addEventListener("input", () => {
|
inp.addEventListener("input", () => {
|
||||||
draft[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
|
zeilen[Number(inp.dataset.i)][inp.dataset.f] = inp.value;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Umbenennen ist hier eine Zeile — das ist der Grund für diese Bauart.
|
||||||
|
// Neu gezeichnet wird dabei nicht, sonst verlöre das Feld den Cursor.
|
||||||
|
list.querySelectorAll("input[data-k]").forEach((inp) => {
|
||||||
|
inp.addEventListener("input", () => {
|
||||||
|
zeilen[Number(inp.dataset.k)].name = inp.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
list.querySelectorAll("[data-del]").forEach((b) =>
|
list.querySelectorAll("[data-del]").forEach((b) =>
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
draft.splice(Number(b.dataset.del), 1);
|
zeilen.splice(Number(b.dataset.del), 1);
|
||||||
|
render(root);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Eine Überschrift löschen heißt: Ihre Apps rutschen in die Gruppe darüber.
|
||||||
|
// Die Apps selbst anzufassen wäre falsch — aufgelöst wird eine Kategorie,
|
||||||
|
// nicht das, was in ihr steht.
|
||||||
|
list.querySelectorAll("[data-kdel]").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
zeilen.splice(Number(b.dataset.kdel), 1);
|
||||||
render(root);
|
render(root);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
|
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
|
||||||
// das klappte dabei alles zu, was man gerade bearbeitet.
|
// das klappte dabei alles zu, was man gerade bearbeitet.
|
||||||
list.querySelectorAll(".approw").forEach((d, i) => {
|
list.querySelectorAll(".approw").forEach((d) => {
|
||||||
|
const i = Number(d.querySelector("[data-up]").dataset.up);
|
||||||
d.addEventListener("toggle", () => {
|
d.addEventListener("toggle", () => {
|
||||||
if (draft[i]) draft[i].__offen = d.open;
|
if (zeilen[i]) zeilen[i].__offen = d.open;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
|
// Die Pfeile sitzen im <summary>: Ohne preventDefault klappt jeder
|
||||||
// Sortierklick die Zeile auf oder zu.
|
// Sortierklick die Zeile auf oder zu. Getauscht wird stumpf Nachbar gegen
|
||||||
|
// Nachbar — dass eine App dabei die Gruppe wechselt oder eine ganze Gruppe
|
||||||
|
// wandert, ergibt sich von selbst und braucht keine eigene Regel.
|
||||||
const tausche = (i, j) => {
|
const tausche = (i, j) => {
|
||||||
if (j < 0 || j >= draft.length) return;
|
if (j < 0 || j >= zeilen.length) return;
|
||||||
[draft[i], draft[j]] = [draft[j], draft[i]];
|
[zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
|
||||||
render(root);
|
render(root);
|
||||||
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
|
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
|
||||||
// kann, ohne die Maus nachzuführen.
|
// kann, ohne die Maus nachzuführen.
|
||||||
@@ -792,6 +959,7 @@ function openApps() {
|
|||||||
`<div class="apped"><div id="appList"></div>
|
`<div class="apped"><div id="appList"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<span class="saveline" id="appMsg"></span>
|
<span class="saveline" id="appMsg"></span>
|
||||||
|
<button class="btn" id="btnKat">+ kategorie</button>
|
||||||
<button class="btn" id="btnAdd">+ neue app</button>
|
<button class="btn" id="btnAdd">+ neue app</button>
|
||||||
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -801,19 +969,26 @@ function openApps() {
|
|||||||
|
|
||||||
root.querySelector("#btnAdd").addEventListener("click", () => {
|
root.querySelector("#btnAdd").addEventListener("click", () => {
|
||||||
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
|
// Immer ans Ende — sortiert wird mit den Pfeilen, nicht beim Anlegen.
|
||||||
draft.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
|
zeilen.push({ id: "", name: "", url: "", glyph: "box", __offen: true });
|
||||||
render(root);
|
render(root);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
root.querySelector("#btnKat").addEventListener("click", () => {
|
||||||
|
zeilen.push({ typ: KOPF, name: "" });
|
||||||
|
render(root);
|
||||||
|
[...root.querySelectorAll(".ae-kat")].pop()?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
|
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
|
||||||
const msg = root.querySelector("#appMsg");
|
const msg = root.querySelector("#appMsg");
|
||||||
msg.dataset.kind = "";
|
msg.dataset.kind = "";
|
||||||
msg.textContent = "speichere …";
|
msg.textContent = "speichere …";
|
||||||
try {
|
try {
|
||||||
const clean = draft.map(({ __offen, ...rest }) => rest);
|
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
|
||||||
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps;
|
const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
|
||||||
const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id));
|
zeilen = zeilenAus(state.cfg.apps).map((z) =>
|
||||||
draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.id) }));
|
z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
|
||||||
|
);
|
||||||
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
|
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
|
||||||
render(root);
|
render(root);
|
||||||
renderMenubarApps();
|
renderMenubarApps();
|
||||||
@@ -881,7 +1056,11 @@ function buildAppMenu() {
|
|||||||
`<div class="sm-head">wechseln zu</div>` +
|
`<div class="sm-head">wechseln zu</div>` +
|
||||||
(state.cfg.apps.length
|
(state.cfg.apps.length
|
||||||
? `<div class="drawer">` +
|
? `<div class="drawer">` +
|
||||||
state.cfg.apps
|
gruppiere(state.cfg.apps)
|
||||||
|
.map(
|
||||||
|
(g) =>
|
||||||
|
(g.kategorie ? `<div class="d-kopf">${escapeHtml(g.kategorie)}</div>` : "") +
|
||||||
|
g.apps
|
||||||
.map(
|
.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
`<button class="d-app" data-app="${escapeAttr(a.id)}">
|
`<button class="d-app" data-app="${escapeAttr(a.id)}">
|
||||||
@@ -890,6 +1069,8 @@ function buildAppMenu() {
|
|||||||
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
|
${a.subtitle ? `<span class="d-sub">${escapeHtml(a.subtitle)}</span>` : ""}
|
||||||
</button>`
|
</button>`
|
||||||
)
|
)
|
||||||
|
.join("")
|
||||||
|
)
|
||||||
.join("") +
|
.join("") +
|
||||||
`</div>`
|
`</div>`
|
||||||
: `<button data-settings="1">keine app eingetragen …</button>`);
|
: `<button data-settings="1">keine app eingetragen …</button>`);
|
||||||
@@ -1145,15 +1326,6 @@ async function main() {
|
|||||||
if (e.target === $("modal")) closeModal();
|
if (e.target === $("modal")) closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Was die HoneyBar aus einer fremden App herüberreicht: Sie darf selbst
|
|
||||||
// nichts schreiben (siehe honeybar.js), also stellt sie die Frage in der
|
|
||||||
// Adresse und der Schreibtisch beantwortet sie. Danach kommt die Adresse
|
|
||||||
// weg — ein Neuladen soll die Einstellungen nicht wieder aufreißen.
|
|
||||||
if (new URLSearchParams(location.search).get("einstellungen") === "1") {
|
|
||||||
history.replaceState(null, "", location.pathname);
|
|
||||||
openSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { api, escapeAttr, escapeHtml } from "../core.js";
|
||||||
|
import { widgetAnmelden, widgetsLaden, widgetsZeichnen } from "./rahmen.js";
|
||||||
|
|
||||||
|
/* ── Das Bild ──────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Genau **ein** Bild je Nutzer, hochgeladen oder verlinkt. Ein neues ersetzt
|
||||||
|
* das alte, ohne Verwaltung und ohne Liste — das ist der ganze Reiz: Es ist
|
||||||
|
* das erste Widget, das nichts abfragt, sondern etwas *ist*.
|
||||||
|
*
|
||||||
|
* Hochgeladen wird zum eigenen Server; verlinkt wird auf eine fremde Adresse.
|
||||||
|
* Der Unterschied ist keiner der Bequemlichkeit: Ein Link holt bei jedem
|
||||||
|
* Aufbau des Schreibtischs etwas von woanders, ein Upload nie.
|
||||||
|
*
|
||||||
|
* DAS WIDGET ZEIGT, DIE EINSTELLUNGEN STELLEN EIN.
|
||||||
|
*
|
||||||
|
* Hochladen, Verlinken und Entfernen standen erst im Widget selbst. Das ist die
|
||||||
|
* Ausnahme gewesen, nicht die Regel: Der Ort des Wetters wird auch nicht in der
|
||||||
|
* Wetterkachel gesucht. Ein Schild auf dem Schreibtisch, das zu drei Vierteln
|
||||||
|
* aus Knöpfen besteht, zeigt vor allem sich selbst.
|
||||||
|
*
|
||||||
|
* Also: Das Widget zeigt nur noch das Bild. Die Knöpfe stehen unter
|
||||||
|
* *Einstellungen → Bild* — dieselbe Datei, aber ein zweiter Ausgang
|
||||||
|
* (`bildFormularHtml` / `bildFormularMontieren`), den os.js einhängt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEER = "Kein Bild — unter Einstellungen → Bild hochladen oder verlinken.";
|
||||||
|
const FORM_ID = "bildForm";
|
||||||
|
|
||||||
|
let art = ""; // "datei" | "link" | ""
|
||||||
|
let quelle = "";
|
||||||
|
let stand = ""; // Meldung im Einstellungsfeld
|
||||||
|
let linkAuf = false;
|
||||||
|
let kaputt = false; // die Adresse liefert kein Bild
|
||||||
|
|
||||||
|
widgetAnmelden({
|
||||||
|
id: "bild",
|
||||||
|
titel: "bild",
|
||||||
|
// Das Bild soll beim Neuzeichnen nicht neu geladen werden — ein Bild, das
|
||||||
|
// alle zwei Minuten kurz weiß aufblitzt, wäre schlechter als keines.
|
||||||
|
stabil: true,
|
||||||
|
|
||||||
|
async laden() {
|
||||||
|
const d = await api("/api/image/info");
|
||||||
|
art = (d && d.art) || "";
|
||||||
|
quelle = (d && d.url) || "";
|
||||||
|
kaputt = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
inhalt() {
|
||||||
|
if (kaputt) {
|
||||||
|
return `<div class="bildw"><p class="w-zustand">${
|
||||||
|
art === "link" ? "Adresse liefert kein Bild" : "Bild nicht lesbar"
|
||||||
|
}</p></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="bildw">${
|
||||||
|
quelle
|
||||||
|
? `<img class="bildw-bild" src="${escapeAttr(quelle)}" alt="" />`
|
||||||
|
: `<p class="w-zustand">${LEER}</p>`
|
||||||
|
}</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
montiert(el) {
|
||||||
|
// Ein Bild, das nicht kommt, soll es sagen — bei einem Link ist das der
|
||||||
|
// Normalfall des Tages, an dem die fremde Seite umzieht.
|
||||||
|
el.querySelector(".bildw-bild")?.addEventListener("error", () => {
|
||||||
|
kaputt = true;
|
||||||
|
melden(art === "link" ? "Adresse liefert kein Bild" : "Bild nicht lesbar");
|
||||||
|
neu();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Der Ausgang in die Einstellungen ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Das Feld für *Einstellungen → Bild*. os.js hängt es ein, kennt es aber nicht. */
|
||||||
|
export function bildFormularHtml() {
|
||||||
|
return `<div id="${FORM_ID}">${formInhalt()}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach dem Einhängen verdrahten. */
|
||||||
|
export function bildFormularMontieren(root) {
|
||||||
|
const el = root.querySelector(`#${FORM_ID}`);
|
||||||
|
if (el) verdrahte(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formInhalt() {
|
||||||
|
const vorschau = quelle
|
||||||
|
? `<img class="bildset-bild" src="${escapeAttr(quelle)}" alt="" />`
|
||||||
|
: `<span class="bildset-leer">kein bild</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bildset">
|
||||||
|
<span class="bildset-vorschau">${vorschau}</span>
|
||||||
|
<div class="bildset-rechts">
|
||||||
|
<div class="bildset-knoepfe">
|
||||||
|
<input class="bildw-datei" type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
|
||||||
|
<button type="button" class="btn bildw-waehlen">${quelle ? "ersetzen" : "hochladen"}</button>
|
||||||
|
<button type="button" class="btn bildw-link">link</button>
|
||||||
|
${quelle ? `<button type="button" class="btn bildw-weg">entfernen</button>` : ""}
|
||||||
|
</div>
|
||||||
|
<span class="w-stand">${escapeHtml(stand)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
linkAuf
|
||||||
|
? `<div class="bildw-linkzeile">
|
||||||
|
<input class="bildw-url" type="url" spellcheck="false"
|
||||||
|
placeholder="https://…" value="${escapeAttr(art === "link" ? quelle : "")}" />
|
||||||
|
<button type="button" class="btn bildw-uebernehmen">setzen</button>
|
||||||
|
</div>`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdrahte(el) {
|
||||||
|
const datei = el.querySelector(".bildw-datei");
|
||||||
|
el.querySelector(".bildw-waehlen").addEventListener("click", () => datei.click());
|
||||||
|
datei.addEventListener("change", () => datei.files[0] && hochladen(datei.files[0]));
|
||||||
|
|
||||||
|
el.querySelector(".bildw-link").addEventListener("click", () => {
|
||||||
|
linkAuf = !linkAuf;
|
||||||
|
formNeu();
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = el.querySelector(".bildw-url");
|
||||||
|
if (url) {
|
||||||
|
url.focus();
|
||||||
|
const setzen = () => linkSetzen(url.value.trim());
|
||||||
|
el.querySelector(".bildw-uebernehmen").addEventListener("click", setzen);
|
||||||
|
url.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
setzen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
el.querySelector(".bildw-weg")?.addEventListener("click", () => linkSetzen(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Einstellungsfeld neu zeichnen — es steht nicht unter dem Widget-Rahmen. */
|
||||||
|
function formNeu() {
|
||||||
|
const el = document.getElementById(FORM_ID);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = formInhalt();
|
||||||
|
verdrahte(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hochladen(file) {
|
||||||
|
melden("lädt hoch …");
|
||||||
|
try {
|
||||||
|
await api("/api/image", { method: "POST", body: file, headers: { "Content-Type": file.type || "application/octet-stream" } });
|
||||||
|
linkAuf = false;
|
||||||
|
await widgetsLaden("bild");
|
||||||
|
melden("");
|
||||||
|
} catch (e) {
|
||||||
|
melden(e.message);
|
||||||
|
}
|
||||||
|
formNeu();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkSetzen(adresse) {
|
||||||
|
melden(adresse ? "übernehme …" : "entferne …");
|
||||||
|
try {
|
||||||
|
await api("/api/image", { method: "PUT", body: JSON.stringify({ url: adresse }) });
|
||||||
|
linkAuf = false;
|
||||||
|
await widgetsLaden("bild");
|
||||||
|
melden("");
|
||||||
|
} catch (e) {
|
||||||
|
melden(e.message);
|
||||||
|
}
|
||||||
|
formNeu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function melden(s) {
|
||||||
|
stand = s;
|
||||||
|
const el = document.querySelector(`#${FORM_ID} .w-stand`);
|
||||||
|
if (el) el.textContent = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neu zeichnen — stabile Widgets lässt der Rahmen sonst in Ruhe. */
|
||||||
|
function neu() {
|
||||||
|
document.querySelectorAll('[data-widget="bild"]').forEach((el) => delete el.dataset.zustand);
|
||||||
|
widgetsZeichnen();
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import { api, escapeHtml } from "../core.js";
|
||||||
|
import { widgetAnmelden, widgetsZeichnen } from "./rahmen.js";
|
||||||
|
|
||||||
|
/* ── Die Notiz ─────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Ein Text, mehr nicht. **InkNote gibt es schon** — die Notiz darf keine zweite
|
||||||
|
* Notiz-App werden: kein Titel, keine Liste von Notizen, keine Ordner, keine
|
||||||
|
* Suche. Genau ein Text, den man im Vorbeigehen benutzt: Einkaufszettel, eine
|
||||||
|
* Nummer, ein Gedanke. Alles, was länger als einen Tag lebt, gehört nach
|
||||||
|
* InkNote.
|
||||||
|
*
|
||||||
|
* SOFORT-ANSICHT — warum zeilenweise?
|
||||||
|
*
|
||||||
|
* Vorher gab es zwei Ansichten: gelesen gesetzt, geschrieben roh. Wer schrieb,
|
||||||
|
* sah `- [ ] Milch` und musste sich merken, wie das gleich aussehen wird. Das
|
||||||
|
* ist die Verwirrung, die hier weg soll.
|
||||||
|
*
|
||||||
|
* Der Text ist deshalb **immer** gesetzt zu sehen; roh ist nur die eine Zeile,
|
||||||
|
* in der die Schreibmarke steht. Man tippt also in einer Aufzählung, sieht aber
|
||||||
|
* ringsum schon Überschriften, Häkchen und Punkte.
|
||||||
|
*
|
||||||
|
* Der Weg dorthin wäre auch anders gegangen — ein `contenteditable`, das
|
||||||
|
* fertiges HTML entgegennimmt und daraus wieder Markdown macht. Das ist die
|
||||||
|
* Bauform, die in jedem Editor Ärger macht: Der Browser erzeugt beim Tippen
|
||||||
|
* eigenes HTML (`<div>`, `<span style>`, beim Einfügen ganze Fremdkörper), und
|
||||||
|
* aus dem muss man den Quelltext *zurückraten*. Ein Fehler dabei kostet Text.
|
||||||
|
*
|
||||||
|
* Hier bleibt der Quelltext die Wahrheit und wird nie zurückgerechnet: Die
|
||||||
|
* Anzeige entsteht aus ihm, und geändert wird immer nur genau eine Zeile.
|
||||||
|
* Deshalb kann hier nichts verlorengehen, was man nicht selbst gelöscht hat.
|
||||||
|
*
|
||||||
|
* Für größere Umbauten (alles markieren, Absätze verschieben) gibt es den
|
||||||
|
* Knopf „quelltext": dann doch das ganze Feld, roh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEER = "Noch nichts notiert.";
|
||||||
|
const VERZUG = 800; // ms bis zum Speichern beim Tippen
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
let zeile = -1; // welche Zeile gerade roh bearbeitet wird, -1 = keine
|
||||||
|
let marke = 0; // wohin die Schreibmarke nach dem Neuzeichnen gehört
|
||||||
|
let roh = false; // ganzes Feld roh (der Umweg für größere Umbauten)
|
||||||
|
let stand = ""; // "speichert …" | "gespeichert" | Fehlertext
|
||||||
|
let uhr = null;
|
||||||
|
|
||||||
|
const zeilen = () => text.split("\n");
|
||||||
|
const setzen = (arr) => {
|
||||||
|
text = arr.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
widgetAnmelden({
|
||||||
|
id: "notiz",
|
||||||
|
titel: "notiz",
|
||||||
|
// Die Notiz hält ein Textfeld: Ihr Inhalt darf nicht bei jedem Neuzeichnen
|
||||||
|
// ersetzt werden, sonst verliert man beim Tippen Text und Schreibmarke.
|
||||||
|
stabil: true,
|
||||||
|
|
||||||
|
async laden() {
|
||||||
|
const d = await api("/api/note");
|
||||||
|
text = (d && d.text) || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
inhalt() {
|
||||||
|
if (roh) {
|
||||||
|
return `
|
||||||
|
<textarea class="notiz-feld" spellcheck="false"
|
||||||
|
placeholder="# Einkauf - [ ] Milch - [x] Brot">${escapeHtml(text)}</textarea>
|
||||||
|
${fuss("fertig")}`;
|
||||||
|
}
|
||||||
|
const koerper = text.trim() || zeile >= 0
|
||||||
|
? `<div class="notiz-text">${markdown(text, zeile)}</div>`
|
||||||
|
: `<div class="notiz-text notiz-leer"><p class="w-zustand">${LEER}</p></div>`;
|
||||||
|
// Die Werkzeuge nur beim Schreiben: Sie wirken auf die Zeile, in der die
|
||||||
|
// Schreibmarke steht — ohne eine solche Zeile stünden sechs Knöpfe da, die
|
||||||
|
// nichts anfassen können. Und eine Notiz, die man nur liest, soll wie ein
|
||||||
|
// Text aussehen und nicht wie ein Formular.
|
||||||
|
return `${zeile >= 0 ? werkzeuge() : ""}${koerper}${fuss("quelltext")}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
montiert(el) {
|
||||||
|
el.querySelectorAll("[data-tat]").forEach((b) => {
|
||||||
|
// Nicht auf „click": Ein Mausklick nimmt dem Textfeld vorher den Fokus,
|
||||||
|
// und mit ihm wüsste man nicht mehr, für welche Zeile der Knopf gilt.
|
||||||
|
b.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
tat(b.dataset.tat);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roh) {
|
||||||
|
const feld = el.querySelector(".notiz-feld");
|
||||||
|
feld.focus();
|
||||||
|
feld.setSelectionRange(feld.value.length, feld.value.length);
|
||||||
|
feld.addEventListener("input", () => {
|
||||||
|
text = feld.value;
|
||||||
|
spaeterSpeichern();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = el.querySelector(".notiz-zeile");
|
||||||
|
if (eingabe) {
|
||||||
|
eingabe.focus();
|
||||||
|
eingabe.setSelectionRange(marke, marke);
|
||||||
|
wachsen(eingabe);
|
||||||
|
eingabe.addEventListener("input", () => {
|
||||||
|
const z = zeilen();
|
||||||
|
z[zeile] = eingabe.value;
|
||||||
|
setzen(z);
|
||||||
|
wachsen(eingabe);
|
||||||
|
spaeterSpeichern();
|
||||||
|
});
|
||||||
|
eingabe.addEventListener("keydown", (e) => taste(e, eingabe));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anklicken heißt: hier weiterschreiben.
|
||||||
|
el.querySelectorAll("[data-z]").forEach((b) =>
|
||||||
|
b.addEventListener("mousedown", (e) => {
|
||||||
|
if (e.target.closest(".notiz-zeile, .notiz-hakbox")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const i = Number(b.dataset.z);
|
||||||
|
springen(i, zeilen()[i].length);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
el.querySelector(".notiz-leer")?.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
springen(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Häkchen lassen sich setzen, ohne in den Text zu springen — das ist der
|
||||||
|
// häufigste Griff an einer Liste.
|
||||||
|
el.querySelectorAll(".notiz-hakbox").forEach((box) =>
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
text = hakenUmlegen(text, Number(box.dataset.hak));
|
||||||
|
neu();
|
||||||
|
jetztSpeichern();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Die kleine Werkzeugleiste ─────────────────────────────────────────────
|
||||||
|
* Sechs Knöpfe, keine Auswahlfelder. Alles, was das kleine Markdown kann, und
|
||||||
|
* nichts darüber hinaus — eine Leiste mit Knöpfen für Dinge, die die Notiz gar
|
||||||
|
* nicht kennt, wäre schlimmer als keine.
|
||||||
|
*
|
||||||
|
* Sie erscheint erst, wenn man in einer Zeile steht, und verschwindet wieder,
|
||||||
|
* sobald man fertig ist.
|
||||||
|
*/
|
||||||
|
const KNOEPFE = [
|
||||||
|
["haken", "☐", "Aufgabe"],
|
||||||
|
["punkt", "•", "Aufzählung"],
|
||||||
|
["nummer", "1.", "Nummerierte Liste"],
|
||||||
|
["ueber", "H", "Überschrift"],
|
||||||
|
["fett", "B", "Fett"],
|
||||||
|
["linie", "—", "Trennlinie"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function werkzeuge() {
|
||||||
|
return (
|
||||||
|
`<div class="notiz-werkzeuge">` +
|
||||||
|
KNOEPFE.map(
|
||||||
|
([tat, zeichen, titel]) =>
|
||||||
|
`<button type="button" class="notiz-wz nw-${tat}" data-tat="${tat}" title="${titel}">${zeichen}</button>`
|
||||||
|
).join("") +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fuss(knopf) {
|
||||||
|
return `
|
||||||
|
<div class="notiz-fuss">
|
||||||
|
<span class="notiz-stand">${escapeHtml(stand)}</span>
|
||||||
|
<button type="button" class="btn notiz-modus" data-tat="modus">${knopf}</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was die Knöpfe tun. Alle arbeiten auf der Zeile, in der man gerade steht. */
|
||||||
|
function tat(name) {
|
||||||
|
if (name === "modus") {
|
||||||
|
roh = !roh;
|
||||||
|
zeile = -1;
|
||||||
|
jetztSpeichern();
|
||||||
|
neu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Schreibmarke gibt es keine „aktuelle Zeile" — dann eine neue am Ende.
|
||||||
|
if (zeile < 0) {
|
||||||
|
const z = zeilen();
|
||||||
|
if (z[z.length - 1].trim()) z.push("");
|
||||||
|
setzen(z);
|
||||||
|
zeile = z.length - 1;
|
||||||
|
marke = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const z = zeilen();
|
||||||
|
if (name === "linie") {
|
||||||
|
z.splice(zeile + (z[zeile].trim() ? 1 : 0), 0, "---", "");
|
||||||
|
setzen(z);
|
||||||
|
zeile += z[zeile].trim() ? 2 : 1;
|
||||||
|
marke = 0;
|
||||||
|
neu();
|
||||||
|
jetztSpeichern();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "fett") {
|
||||||
|
const feld = document.querySelector(".notiz-zeile");
|
||||||
|
const a = feld ? feld.selectionStart : z[zeile].length;
|
||||||
|
const b = feld ? feld.selectionEnd : a;
|
||||||
|
const wort = z[zeile].slice(a, b);
|
||||||
|
z[zeile] = z[zeile].slice(0, a) + "**" + wort + "**" + z[zeile].slice(b);
|
||||||
|
marke = wort ? b + 4 : a + 2; // ohne Auswahl: zwischen die Sterne
|
||||||
|
setzen(z);
|
||||||
|
neu();
|
||||||
|
jetztSpeichern();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VORSATZ = { haken: "- [ ] ", punkt: "- ", nummer: "1. ", ueber: "# " };
|
||||||
|
const alt = vorsatz(z[zeile]);
|
||||||
|
const rest = z[zeile].slice(alt.length);
|
||||||
|
const neuerVorsatz = alt === VORSATZ[name] ? "" : VORSATZ[name];
|
||||||
|
z[zeile] = neuerVorsatz + rest;
|
||||||
|
marke = Math.max(0, marke - alt.length) + neuerVorsatz.length;
|
||||||
|
setzen(z);
|
||||||
|
neu();
|
||||||
|
jetztSpeichern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Listen- oder Überschriften-Vorsatz einer Zeile, samt Leerzeichen. */
|
||||||
|
function vorsatz(z) {
|
||||||
|
const m = /^(\s*(?:[-*]\s+\[[ xX]\]\s+|[-*]\s+|\d+[.)]\s+|#{1,3}\s+))/.exec(z);
|
||||||
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tastatur in der einen rohen Zeile ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function taste(e, feld) {
|
||||||
|
const i = zeile;
|
||||||
|
const z = zeilen();
|
||||||
|
const anfang = feld.selectionStart === 0 && feld.selectionEnd === 0;
|
||||||
|
const ende = feld.selectionStart === feld.value.length && feld.selectionEnd === feld.value.length;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const links = feld.value.slice(0, feld.selectionStart);
|
||||||
|
const rechts = feld.value.slice(feld.selectionEnd);
|
||||||
|
const v = vorsatz(links);
|
||||||
|
|
||||||
|
// Enter auf einer leeren Aufzählungszeile beendet die Liste, statt einen
|
||||||
|
// weiteren leeren Punkt anzuhängen.
|
||||||
|
if (v && !links.slice(v.length).trim() && !rechts) {
|
||||||
|
z[i] = "";
|
||||||
|
setzen(z);
|
||||||
|
springen(i, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
z.splice(i, 1, links, weiterfuehren(v) + rechts);
|
||||||
|
setzen(z);
|
||||||
|
springen(i + 1, weiterfuehren(v).length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace" && anfang) {
|
||||||
|
const v = vorsatz(feld.value);
|
||||||
|
if (v) {
|
||||||
|
// Erst den Vorsatz weg, dann erst die Zeile: „Backspace am Zeilenanfang"
|
||||||
|
// heißt meistens „das ist doch kein Aufzählungspunkt".
|
||||||
|
e.preventDefault();
|
||||||
|
z[i] = feld.value.slice(v.length);
|
||||||
|
setzen(z);
|
||||||
|
springen(i, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const stelle = z[i - 1].length;
|
||||||
|
z.splice(i - 1, 2, z[i - 1] + z[i]);
|
||||||
|
setzen(z);
|
||||||
|
springen(i - 1, stelle);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Delete" && ende && i < z.length - 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
const stelle = z[i].length;
|
||||||
|
z.splice(i, 2, z[i] + z[i + 1]);
|
||||||
|
setzen(z);
|
||||||
|
springen(i, stelle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" && anfang && i > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
springen(i - 1, z[i - 1].length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown" && ende && i < z.length - 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
springen(i + 1, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
beenden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was nach Enter am Anfang der neuen Zeile steht. */
|
||||||
|
function weiterfuehren(v) {
|
||||||
|
if (!v || v.trim().startsWith("#")) return "";
|
||||||
|
const haken = /^(\s*[-*]\s+)\[[ xX]\]\s+$/.exec(v);
|
||||||
|
if (haken) return haken[1] + "[ ] "; // eine neue Aufgabe ist offen
|
||||||
|
const nummer = /^(\s*)(\d+)([.)]\s+)$/.exec(v);
|
||||||
|
if (nummer) return nummer[1] + (Number(nummer[2]) + 1) + nummer[3];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function springen(i, stelle) {
|
||||||
|
zeile = i;
|
||||||
|
marke = stelle;
|
||||||
|
neu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beenden() {
|
||||||
|
zeile = -1;
|
||||||
|
neu();
|
||||||
|
jetztSpeichern();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Klick irgendwo anders hin heißt: fertig mit dieser Zeile. Nicht über
|
||||||
|
// „blur" — den löst auch jeder Knopf in der Werkzeugleiste aus.
|
||||||
|
//
|
||||||
|
// In der **Erfassungsphase**, und das ist hier keine Feinheit: Ein Klick in die
|
||||||
|
// Notiz zeichnet sie neu, und danach hängt das angeklickte Element nicht mehr
|
||||||
|
// im Dokument. `closest()` liefe dann ins Leere und dieser Zuhörer hielte einen
|
||||||
|
// Klick auf die Notiz für einen Klick nach draußen — er würde also genau das
|
||||||
|
// beenden, was der Klick gerade geöffnet hat.
|
||||||
|
document.addEventListener(
|
||||||
|
"mousedown",
|
||||||
|
(e) => {
|
||||||
|
if (zeile < 0 || e.target.closest('[data-widget="notiz"]')) return;
|
||||||
|
beenden();
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Neu zeichnen — der Rahmen lässt stabile Widgets sonst in Ruhe. */
|
||||||
|
function neu() {
|
||||||
|
document.querySelectorAll('[data-widget="notiz"]').forEach((el) => delete el.dataset.zustand);
|
||||||
|
widgetsZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Zeilenfeld ist so hoch wie sein Text — es soll aussehen wie die Zeile,
|
||||||
|
* die es ersetzt, nicht wie ein Formular. */
|
||||||
|
function wachsen(feld) {
|
||||||
|
feld.style.height = "auto";
|
||||||
|
feld.style.height = feld.scrollHeight + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Speichern ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function spaeterSpeichern() {
|
||||||
|
stand = "";
|
||||||
|
clearTimeout(uhr);
|
||||||
|
uhr = setTimeout(jetztSpeichern, VERZUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert, wenn sich etwas geändert hat.
|
||||||
|
*
|
||||||
|
* Zwei offene Geräte überschreiben einander; der letzte Stand gewinnt. Für eine
|
||||||
|
* Notiz ist das vertretbar — man sollte es nur wissen und nicht später
|
||||||
|
* „Datenverlust" nennen.
|
||||||
|
*/
|
||||||
|
let gespeichert = null;
|
||||||
|
async function jetztSpeichern() {
|
||||||
|
clearTimeout(uhr);
|
||||||
|
if (text === gespeichert) return;
|
||||||
|
const wollte = text;
|
||||||
|
standSetzen("speichert …");
|
||||||
|
try {
|
||||||
|
await api("/api/note", { method: "PUT", body: JSON.stringify({ text: wollte }) });
|
||||||
|
gespeichert = wollte;
|
||||||
|
standSetzen("gespeichert");
|
||||||
|
} catch (e) {
|
||||||
|
standSetzen(e.message || "nicht gespeichert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function standSetzen(s) {
|
||||||
|
stand = s;
|
||||||
|
document.querySelectorAll('[data-widget="notiz"] .notiz-stand').forEach((el) => {
|
||||||
|
el.textContent = s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Das kleine Markdown ───────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Absichtlich von Hand und absichtlich klein: Überschriften, Häkchen, Striche,
|
||||||
|
* Aufzählungen, fett, kursiv, Code. Keine Bibliothek — HoneyOS hat keinen
|
||||||
|
* Bauschritt, und für sechs Regeln lohnt kein Nachladen.
|
||||||
|
*
|
||||||
|
* **Zuerst wird escapt, dann gesetzt.** Der Text ist zwar der eigene, aber eine
|
||||||
|
* Notiz, die `<script>` ausführt, wäre eine Falle für den Tag, an dem man etwas
|
||||||
|
* hineinkopiert.
|
||||||
|
*
|
||||||
|
* `bearbeitet` ist die Zeile, die roh gezeigt wird. Sie behält ihren Platz in
|
||||||
|
* der Liste, in der sie steht — sonst spränge der Punkt beim Hineinklicken aus
|
||||||
|
* der Aufzählung heraus.
|
||||||
|
*/
|
||||||
|
export function markdown(quelle, bearbeitet = -1) {
|
||||||
|
const zln = quelle.split("\n");
|
||||||
|
let html = "";
|
||||||
|
let liste = null; // "ul" | "ol" | null
|
||||||
|
|
||||||
|
// Die Häkchen werden über den **Quelltext** gezählt, nicht über das, was man
|
||||||
|
// sieht: Die gerade bearbeitete Zeile zeigt keines, ihre Nummer muss aber
|
||||||
|
// vergeben bleiben, sonst schaltet ein Klick darunter das falsche um.
|
||||||
|
const hakNr = {};
|
||||||
|
let n = -1;
|
||||||
|
zln.forEach((z, i) => {
|
||||||
|
if (HAKEN.test(z)) hakNr[i] = ++n;
|
||||||
|
});
|
||||||
|
|
||||||
|
const listeZu = () => {
|
||||||
|
if (liste) html += `</${liste}>`;
|
||||||
|
liste = null;
|
||||||
|
};
|
||||||
|
const listeAuf = (art) => {
|
||||||
|
if (liste !== art) {
|
||||||
|
listeZu();
|
||||||
|
html += `<${art}>`;
|
||||||
|
liste = art;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
zln.forEach((rohzeile, i) => {
|
||||||
|
const z = rohzeile.trimEnd();
|
||||||
|
|
||||||
|
if (i === bearbeitet) {
|
||||||
|
const feld =
|
||||||
|
`<textarea class="notiz-zeile" rows="1" spellcheck="false" ` +
|
||||||
|
`placeholder="schreiben …">${escapeHtml(rohzeile)}</textarea>`;
|
||||||
|
const art = HAKEN.test(z) || PUNKT.test(z) ? "ul" : ZAHL.test(z) ? "ol" : null;
|
||||||
|
if (art) {
|
||||||
|
listeAuf(art);
|
||||||
|
html += `<li class="notiz-rohli">${feld}</li>`;
|
||||||
|
} else {
|
||||||
|
listeZu();
|
||||||
|
html += feld;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.trim()) {
|
||||||
|
listeZu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ueber = /^(#{1,3})\s+(.*)$/.exec(z);
|
||||||
|
if (ueber) {
|
||||||
|
listeZu();
|
||||||
|
const g = ueber[1].length + 2; // # → h3, ## → h4, ### → h5
|
||||||
|
html += `<h${g} data-z="${i}">${inline(ueber[2])}</h${g}>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(-{3,}|\*{3,})$/.test(z.trim())) {
|
||||||
|
listeZu();
|
||||||
|
html += `<hr data-z="${i}" />`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hak = HAKEN.exec(z);
|
||||||
|
if (hak) {
|
||||||
|
listeAuf("ul");
|
||||||
|
const an = hak[1].toLowerCase() === "x";
|
||||||
|
html +=
|
||||||
|
`<li class="notiz-hak${an ? " ab" : ""}" data-z="${i}">` +
|
||||||
|
`<label><input class="notiz-hakbox" type="checkbox" data-hak="${hakNr[i]}"${an ? " checked" : ""} />` +
|
||||||
|
`<span>${inline(hak[2])}</span></label></li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const punkt = PUNKT.exec(z);
|
||||||
|
if (punkt) {
|
||||||
|
listeAuf("ul");
|
||||||
|
html += `<li data-z="${i}">${inline(punkt[1])}</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zahl = ZAHL.exec(z);
|
||||||
|
if (zahl) {
|
||||||
|
listeAuf("ol");
|
||||||
|
html += `<li data-z="${i}">${inline(zahl[1])}</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listeZu();
|
||||||
|
html += `<p data-z="${i}">${inline(z)}</p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
listeZu();
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HAKEN = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/;
|
||||||
|
const PUNKT = /^\s*[-*]\s+(.*)$/;
|
||||||
|
const ZAHL = /^\s*\d+[.)]\s+(.*)$/;
|
||||||
|
|
||||||
|
function inline(s) {
|
||||||
|
return escapeHtml(s)
|
||||||
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
||||||
|
.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, "$1<i>$2</i>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt das n-te Häkchen im Quelltext um — gezählt wie beim Setzen. */
|
||||||
|
export function hakenUmlegen(quelle, n) {
|
||||||
|
let i = -1;
|
||||||
|
return quelle
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => {
|
||||||
|
const hak = /^(\s*[-*]\s+\[)([ xX])(\]\s+.*)$/.exec(z);
|
||||||
|
if (!hak) return z;
|
||||||
|
i++;
|
||||||
|
if (i !== n) return z;
|
||||||
|
return hak[1] + (hak[2].toLowerCase() === "x" ? " " : "x") + hak[3];
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Verlassen der Seite noch schnell sichern — der Verzug beim Tippen darf
|
||||||
|
// keinen Satz kosten, nur weil der Tab zugeht.
|
||||||
|
//
|
||||||
|
// `keepalive` statt sendBeacon: Der Beacon kann nur POST und trägt keine
|
||||||
|
// eigenen Kopfzeilen — unser Weg ist ein PUT mit Prüfwort. Ein Beacon wäre
|
||||||
|
// hier also nicht schneller, sondern schlicht abgewiesen worden.
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
if (text === gespeichert) return;
|
||||||
|
api("/api/note", { method: "PUT", body: JSON.stringify({ text }), keepalive: true }).catch(() => {});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/* ── Der Widget-Rahmen ─────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Ein Widget ist hier eine **Beschreibung**, kein Stück HTML im Dokument.
|
||||||
|
* Vorher standen Benutzer und Wetter fest in index.html, jedes mit eigener
|
||||||
|
* Zeigen-und-Verstecken-Logik in seiner Datei — beim dritten Widget hätte man
|
||||||
|
* dieselben zwanzig Zeilen ein drittes Mal geschrieben.
|
||||||
|
*
|
||||||
|
* Der Rahmen kümmert sich um das, was jedes Widget braucht und keines für sich
|
||||||
|
* lösen sollte:
|
||||||
|
*
|
||||||
|
* · beide Betriebsarten — Fenster im Terminal, Schild auf dem Schreibtisch
|
||||||
|
* · die vier Zustände: lädt · fertig · nichts da · Fehler
|
||||||
|
* · Reihenfolge und Nachladen im Takt
|
||||||
|
*
|
||||||
|
* Ein Widget beschreibt sich so:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* id, titel,
|
||||||
|
* wo: ["terminal", "desktop"] // wo es erscheinen darf
|
||||||
|
* sichtbar: () => true // Schalter aus → gar nicht erst da
|
||||||
|
* laden: async () => false|void // false = es gibt nichts zu zeigen
|
||||||
|
* leer: "verbergen" | "Text" // was dann passiert
|
||||||
|
* inhalt: () => "<html>"
|
||||||
|
* takt: 600000 // ms, optional
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Absichtlich offen gelassen: eine dritte Form. Die Widgets sollen später auch
|
||||||
|
* als Aufklappfeld in der HoneyBar leben können — deshalb erzeugt `inhalt()`
|
||||||
|
* nur den Kern, und die Verpackung entsteht hier.
|
||||||
|
*/
|
||||||
|
import { $, saveSettings, state } from "../core.js";
|
||||||
|
|
||||||
|
const REGISTER = [];
|
||||||
|
/** id → "laedt" | "fertig" | "leer" | "fehler" */
|
||||||
|
const zustand = {};
|
||||||
|
const uhren = {};
|
||||||
|
/** Was schon einmal geladen wurde — ein Widget, das erst später eingeschaltet
|
||||||
|
* wird, holt seine Daten dann nach. */
|
||||||
|
const geladen = new Set();
|
||||||
|
|
||||||
|
export function widgetAnmelden(def) {
|
||||||
|
REGISTER.push(def);
|
||||||
|
zustand[def.id] = def.laden ? "laedt" : "fertig";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur zum Nachsehen von außen — die Liste selbst gehört dem Rahmen. */
|
||||||
|
export function widgetListe() {
|
||||||
|
return REGISTER.map((w) => w.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Reihenfolge fest. Was nicht genannt ist, hängt sich hinten an.
|
||||||
|
*
|
||||||
|
* Warum überhaupt? Weil die Reihenfolge sonst davon abhinge, in welcher
|
||||||
|
* Reihenfolge die Module ausgewertet werden — und das ist **nicht** die
|
||||||
|
* Reihenfolge der Importzeilen: `glyphs.js` lädt seine Raster mit `await` auf
|
||||||
|
* oberster Ebene, also warten alle Module, die es brauchen, während die
|
||||||
|
* anderen schon durchlaufen. Der Systemzustand braucht keine Symbole und stand
|
||||||
|
* deshalb vor dem Benutzer, obwohl er darunter importiert wird.
|
||||||
|
*
|
||||||
|
* Hier ist die Reihenfolge damit eine Angabe statt einer Nebenwirkung. Sie ist
|
||||||
|
* die **Grundstellung**: Was der Nutzer selbst sortiert hat, steht in seinen
|
||||||
|
* Einstellungen und schlägt sie (siehe widgetPlan).
|
||||||
|
*/
|
||||||
|
export function widgetsOrdnen(ids) {
|
||||||
|
const platz = (w) => {
|
||||||
|
const i = ids.indexOf(w.id);
|
||||||
|
return i < 0 ? ids.length : i;
|
||||||
|
};
|
||||||
|
REGISTER.sort((a, b) => platz(a) - platz(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Was der Nutzer eingestellt hat ────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Reihenfolge und An/Aus stehen in **einer** Einstellung, als Liste von IDs mit
|
||||||
|
* einem Minus vor dem, was aus ist:
|
||||||
|
*
|
||||||
|
* widgets = "benutzer,system,-wetter,notiz,bild"
|
||||||
|
*
|
||||||
|
* Ein Feld statt zwei, weil beides zusammen gehört: Wer ein Widget ausschaltet,
|
||||||
|
* soll seinen Platz behalten, wenn er es wieder einschaltet. Und weil die
|
||||||
|
* Einstellungen ohnehin schon je Nutzer auf dem Server liegen, braucht es dafür
|
||||||
|
* weder eine neue Tabelle noch einen neuen Endpunkt.
|
||||||
|
*
|
||||||
|
* Die gespeicherte Liste ist ein Wunsch, kein Gesetz: Unbekannte IDs fallen
|
||||||
|
* weg (ein Widget kann verschwinden), neue hängen sich hinten an und sind an.
|
||||||
|
*/
|
||||||
|
const AUS = "-";
|
||||||
|
|
||||||
|
/** Die Widgets in der Reihenfolge des Nutzers: [{id, titel, wo, an}]. */
|
||||||
|
export function widgetPlan() {
|
||||||
|
const aus = new Set();
|
||||||
|
const reihe = [];
|
||||||
|
String(state.settings.widgets || "")
|
||||||
|
.split(",")
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((e) => {
|
||||||
|
const id = e.startsWith(AUS) ? e.slice(1) : e;
|
||||||
|
if (e.startsWith(AUS)) aus.add(id);
|
||||||
|
if (!reihe.includes(id) && REGISTER.some((w) => w.id === id)) reihe.push(id);
|
||||||
|
});
|
||||||
|
REGISTER.forEach((w) => reihe.includes(w.id) || reihe.push(w.id));
|
||||||
|
|
||||||
|
return reihe.map((id) => {
|
||||||
|
const w = REGISTER.find((x) => x.id === id);
|
||||||
|
return { id, titel: w.titel, wo: w.wo, an: !aus.has(id) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt einen geänderten Plan: speichern, nachladen, neu zeichnen. */
|
||||||
|
export function widgetPlanSetzen(plan) {
|
||||||
|
state.settings.widgets = plan.map((p) => (p.an ? "" : AUS) + p.id).join(",");
|
||||||
|
saveSettings();
|
||||||
|
widgetsZeichnen();
|
||||||
|
// Ein gerade erst eingeschaltetes Widget hat seine Daten noch nie geholt.
|
||||||
|
widgetsLaden();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein Widget (oder alle) und zeichnet neu.
|
||||||
|
*
|
||||||
|
* Fehler bleiben hier: Ein Widget, das seinen Server nicht erreicht, sagt das
|
||||||
|
* in seinem eigenen Rahmen — es darf weder den Schreibtisch aufhalten noch die
|
||||||
|
* anderen Widgets mitreißen.
|
||||||
|
*/
|
||||||
|
export async function widgetsLaden(nurId) {
|
||||||
|
const an = new Set(widgetPlan().filter((p) => p.an).map((p) => p.id));
|
||||||
|
const dran = REGISTER.filter((w) => {
|
||||||
|
if (!w.laden) return false;
|
||||||
|
if (nurId) return w.id === nurId;
|
||||||
|
return an.has(w.id) && !geladen.has(w.id);
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
dran.map(async (w) => {
|
||||||
|
geladen.add(w.id);
|
||||||
|
zustand[w.id] = "laedt";
|
||||||
|
widgetsZeichnen();
|
||||||
|
try {
|
||||||
|
zustand[w.id] = (await w.laden()) === false ? "leer" : "fertig";
|
||||||
|
} catch {
|
||||||
|
zustand[w.id] = "fehler";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
widgetsZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startet die Nachlade-Takte. Einmal beim Aufbau des Schreibtischs. */
|
||||||
|
export function widgetsStarten() {
|
||||||
|
REGISTER.forEach((w) => {
|
||||||
|
if (!w.takt || uhren[w.id]) return;
|
||||||
|
uhren[w.id] = setInterval(() => {
|
||||||
|
if (widgetPlan().find((p) => p.id === w.id)?.an) widgetsLaden(w.id);
|
||||||
|
}, w.takt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function zustandHtml(w) {
|
||||||
|
switch (zustand[w.id]) {
|
||||||
|
case "laedt":
|
||||||
|
return `<p class="w-zustand">lädt …</p>`;
|
||||||
|
case "fehler":
|
||||||
|
return `<p class="w-zustand w-fehler">nicht erreichbar</p>`;
|
||||||
|
case "leer":
|
||||||
|
return `<p class="w-zustand">${w.leer}</p>`;
|
||||||
|
default:
|
||||||
|
return w.inhalt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet alle Widgets in die Ansicht, die gerade gilt.
|
||||||
|
*
|
||||||
|
* Jedes Widget bekommt seinen eigenen Knoten, der stehen bleibt, solange es
|
||||||
|
* gebraucht wird. Der erste Entwurf hat beide Behälter bei jedem Durchgang neu
|
||||||
|
* gefüllt — kürzer, aber falsch, sobald ein Widget etwas im DOM hält: Der
|
||||||
|
* Zettel wird alle zwei Minuten mitgezeichnet (die Apps werden dann angeklopft),
|
||||||
|
* und wer gerade tippte, verlor Text und Schreibmarke.
|
||||||
|
*
|
||||||
|
* Deshalb zwei Zusagen an die Widgets:
|
||||||
|
* · `stabil: true` — dein Inhalt wird nicht neu gesetzt, solange sich dein
|
||||||
|
* Zustand nicht ändert. Du führst ihn selbst nach.
|
||||||
|
* · `montiert(el)` — wird nach jedem Neusetzen gerufen, zum Verdrahten.
|
||||||
|
*/
|
||||||
|
function baue(behaelter, dran, alsFenster) {
|
||||||
|
const alt = new Map([...behaelter.children].map((el) => [el.dataset.widget, el]));
|
||||||
|
|
||||||
|
dran.forEach((w) => {
|
||||||
|
let el = alt.get(w.id);
|
||||||
|
alt.delete(w.id);
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(alsFenster ? "section" : "div");
|
||||||
|
el.dataset.widget = w.id;
|
||||||
|
el.className = alsFenster ? "win win-widget" : "deskwidget";
|
||||||
|
if (alsFenster) {
|
||||||
|
el.innerHTML =
|
||||||
|
`<div class="win-bar"><span class="win-title">${w.titel}</span>` +
|
||||||
|
`<span class="win-dots">●●●</span></div><div class="win-body"></div>`;
|
||||||
|
}
|
||||||
|
behaelter.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const koerper = alsFenster ? el.querySelector(".win-body") : el;
|
||||||
|
const frisch = el.dataset.zustand !== zustand[w.id];
|
||||||
|
if (frisch || !w.stabil) {
|
||||||
|
koerper.innerHTML = zustandHtml(w);
|
||||||
|
el.dataset.zustand = zustand[w.id];
|
||||||
|
if (w.montiert && zustand[w.id] === "fertig") w.montiert(koerper);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
alt.forEach((el) => el.remove());
|
||||||
|
|
||||||
|
// Reihenfolge nur anfassen, wenn sie nicht stimmt: Einen Knoten zu verschieben
|
||||||
|
// heißt, ihn kurz aus dem Dokument zu nehmen — ein Textfeld verliert dabei
|
||||||
|
// die Schreibmarke.
|
||||||
|
const soll = dran.map((w) => w.id).join(",");
|
||||||
|
const ist = [...behaelter.children].map((el) => el.dataset.widget).join(",");
|
||||||
|
if (soll !== ist) {
|
||||||
|
dran.forEach((w) => behaelter.appendChild(behaelter.querySelector(`[data-widget="${w.id}"]`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wählt aus, was gerade dran ist, und lässt es zeichnen.
|
||||||
|
*/
|
||||||
|
export function widgetsZeichnen() {
|
||||||
|
const desktop = state.settings.mode === "desktop";
|
||||||
|
const modus = desktop ? "desktop" : "terminal";
|
||||||
|
const spalte = $("widgetSpalte");
|
||||||
|
const schilder = $("deskwidgets");
|
||||||
|
if (!spalte || !schilder) return;
|
||||||
|
|
||||||
|
const dran = widgetPlan()
|
||||||
|
.filter((p) => p.an)
|
||||||
|
.map((p) => REGISTER.find((w) => w.id === p.id))
|
||||||
|
.filter((w) => {
|
||||||
|
if (w.wo && !w.wo.includes(modus)) return false;
|
||||||
|
if (w.sichtbar && !w.sichtbar()) return false;
|
||||||
|
// „nichts da" heißt bei manchen Widgets: gar nicht erst hinstellen. Eine
|
||||||
|
// leere Wetterkachel wäre schlechter als keine.
|
||||||
|
return !(zustand[w.id] === "leer" && w.leer === "verbergen");
|
||||||
|
});
|
||||||
|
|
||||||
|
baue(spalte, desktop ? [] : dran, true);
|
||||||
|
baue(schilder, desktop ? dran : [], false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { escapeHtml, state } from "../core.js";
|
||||||
|
import { BIENE_ASCII } from "../biene.js";
|
||||||
|
import { widgetAnmelden } from "./rahmen.js";
|
||||||
|
|
||||||
|
/* ── Systemzustand ─────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Nur im Terminal-Modus ein Widget. Auf dem Schreibtisch gibt es dieselben
|
||||||
|
* Zahlen als Fenster über das Startmenü — ein Schild, das dauerhaft „modus:
|
||||||
|
* desktop" behauptet, während man im Desktop-Modus steht, sagt niemandem etwas.
|
||||||
|
*
|
||||||
|
* Die Biene steht hier **aus Schriftzeichen**, nicht als Bild: Das Fenster
|
||||||
|
* besteht aus lauter Textzeilen, und ein Bild darin wäre ein Fremdkörper — wie
|
||||||
|
* ein Foto in einer Tabelle. Als Text gehört sie dazu, und das Ganze wird zu
|
||||||
|
* dem, was man bei so einem Fenster ohnehin im Kopf hat: einem neofetch.
|
||||||
|
*/
|
||||||
|
export function statsHtml() {
|
||||||
|
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}`],
|
||||||
|
];
|
||||||
|
return `<dl class="statlist">${rows
|
||||||
|
.map(([k, v]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`)
|
||||||
|
.join("")}</dl>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetAnmelden({
|
||||||
|
id: "system",
|
||||||
|
titel: "system",
|
||||||
|
wo: ["terminal"],
|
||||||
|
inhalt: () => `<pre class="biene-text">${BIENE_ASCII}</pre>${statsHtml()}`,
|
||||||
|
});
|
||||||
+12
-17
@@ -1,5 +1,6 @@
|
|||||||
import { $, escapeAttr, state } from "../core.js";
|
import { escapeAttr, escapeHtml, state } from "../core.js";
|
||||||
import { glyphSvg } from "../glyphs.js";
|
import { glyphSvg } from "../glyphs.js";
|
||||||
|
import { widgetAnmelden } from "./rahmen.js";
|
||||||
|
|
||||||
function greeting() {
|
function greeting() {
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
@@ -15,8 +16,13 @@ function greeting() {
|
|||||||
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
|
* die HoneyBar in den Apps nicht hat — beide sollen aber gleich aussehen, sonst
|
||||||
* merkt man beim Wechsel, dass es zwei verschiedene HoneyBars sind. Auf dem
|
* merkt man beim Wechsel, dass es zwei verschiedene HoneyBars sind. Auf dem
|
||||||
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
|
* Schreibtisch ist ohnehin Platz dafür, und die Begrüßung gehört dazu.
|
||||||
|
*
|
||||||
|
* Kein `laden`: Der Name steht schon im Zustand, seit die Seite ihn geholt hat.
|
||||||
*/
|
*/
|
||||||
function userHtml() {
|
widgetAnmelden({
|
||||||
|
id: "benutzer",
|
||||||
|
titel: "angemeldet",
|
||||||
|
inhalt() {
|
||||||
const bild = state.me && state.me.picture
|
const bild = state.me && state.me.picture
|
||||||
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
||||||
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
||||||
@@ -24,21 +30,10 @@ function userHtml() {
|
|||||||
<div class="uw">
|
<div class="uw">
|
||||||
${bild}
|
${bild}
|
||||||
<div class="uw-text">
|
<div class="uw-text">
|
||||||
<b class="uw-name">${state.cfg.user}</b>
|
<b class="uw-name">${escapeHtml(state.cfg.user)}</b>
|
||||||
<span class="uw-gruss">${greeting()}</span>
|
<span class="uw-gruss">${greeting()}</span>
|
||||||
<span class="uw-host">${state.cfg.user}@${state.cfg.hostname}</span>
|
<span class="uw-host">${escapeHtml(state.cfg.user)}@${escapeHtml(state.cfg.hostname)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
|
||||||
}
|
|
||||||
+44
-28
@@ -1,5 +1,6 @@
|
|||||||
import { $, api, state } from "../core.js";
|
import { api, $, state } from "../core.js";
|
||||||
import { glyphSvg } from "../glyphs.js";
|
import { glyphSvg } from "../glyphs.js";
|
||||||
|
import { widgetAnmelden, widgetsLaden } from "./rahmen.js";
|
||||||
|
|
||||||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||||||
*
|
*
|
||||||
@@ -7,21 +8,36 @@ import { glyphSvg } from "../glyphs.js";
|
|||||||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
* 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.
|
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||||||
*/
|
*/
|
||||||
export async function loadWeather() {
|
widgetAnmelden({
|
||||||
|
id: "wetter",
|
||||||
|
titel: "wetter",
|
||||||
|
leer: "verbergen",
|
||||||
|
takt: 10 * 60 * 1000, // derselbe Takt, in dem der Server seinen Speicher erneuert
|
||||||
|
|
||||||
|
async laden() {
|
||||||
if (!state.settings.weatherCity) {
|
if (!state.settings.weatherCity) {
|
||||||
state.weather = null;
|
state.weather = null;
|
||||||
renderWeather();
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
state.weather = await api("/api/weather");
|
state.weather = await api("/api/weather");
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
// Auch im Fehlerfall den alten Stand wegwerfen: Das Widget zeigt dann
|
||||||
|
// „nicht erreichbar", und in der Leiste verschwindet das Symbol. Eine
|
||||||
|
// Temperatur von vorhin, die aussieht wie von jetzt, wäre schlechter.
|
||||||
state.weather = null;
|
state.weather = null;
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
renderWeather();
|
// 204: kein Ort, kein Dienst — der Aufrufhelfer gibt dann nichts zurück.
|
||||||
}
|
if (!state.weather || !state.weather.glyph) {
|
||||||
|
state.weather = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
function weatherHtml(w) {
|
inhalt() {
|
||||||
|
const w = state.weather;
|
||||||
return `
|
return `
|
||||||
<div class="wx">
|
<div class="wx">
|
||||||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||||||
@@ -31,28 +47,22 @@ function weatherHtml(w) {
|
|||||||
<span class="wx-city">${w.ort}</span>
|
<span class="wx-city">${w.ort}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function renderWeather() {
|
/**
|
||||||
const w = state.weather;
|
* Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
|
||||||
const tile = $("wxTile");
|
* honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
|
||||||
const desk = $("wxDesk");
|
* ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||||||
|
*
|
||||||
|
* Sie hängt am selben geladenen Stand, hat aber ihren eigenen Schalter und
|
||||||
|
* gehört nicht in den Widget-Rahmen: Sie ist kein Widget, sondern ein Teil der
|
||||||
|
* Leiste.
|
||||||
|
*/
|
||||||
|
export function renderWeatherBar() {
|
||||||
const bar = $("mbWx");
|
const bar = $("mbWx");
|
||||||
const desktopMode = state.settings.mode === "desktop";
|
if (!bar) return;
|
||||||
|
const w = state.weather;
|
||||||
// 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 inBar = state.settings.weatherBar === "on" && w;
|
const inBar = state.settings.weatherBar === "on" && w;
|
||||||
bar.hidden = !inBar;
|
bar.hidden = !inBar;
|
||||||
if (inBar) {
|
if (inBar) {
|
||||||
@@ -60,3 +70,9 @@ export function renderWeather() {
|
|||||||
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
bar.title = `${w.ort}: ${w.text}, ${Math.round(w.grad)}°`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Beides zusammen: Widget und Leiste hängen am selben Abruf. */
|
||||||
|
export async function loadWeather() {
|
||||||
|
await widgetsLaden("wetter");
|
||||||
|
renderWeatherBar();
|
||||||
|
}
|
||||||
+182
@@ -4,8 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"honeyos/internal/auth"
|
||||||
"honeyos/internal/store"
|
"honeyos/internal/store"
|
||||||
"honeyos/internal/weather"
|
"honeyos/internal/weather"
|
||||||
)
|
)
|
||||||
@@ -43,3 +46,182 @@ func TestMitOrtKommtDasWetter(t *testing.T) {
|
|||||||
t.Fatalf("falsches Wetter: %+v", w)
|
t.Fatalf("falsches Wetter: %+v", w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ist ein Ort eingestellt und der Dienst fällt aus, ist das etwas anderes als
|
||||||
|
// „nichts eingestellt": Es gibt etwas zu zeigen, es kommt nur gerade nicht. Die
|
||||||
|
// Oberfläche unterscheidet das — 204 verschwindet still, 502 sagt „nicht
|
||||||
|
// erreichbar". Beides als 204 zu melden hiess: Ein Ausfall sah aus wie ein
|
||||||
|
// abgeschaltetes Widget.
|
||||||
|
func TestAusfallIstNichtDasselbeWieNichts(t *testing.T) {
|
||||||
|
s := newTestServer(t, "")
|
||||||
|
s.wetter = weather.KaputtFuerTest(t)
|
||||||
|
if err := s.store.SetSettings(store.LocalSub, map[string]string{
|
||||||
|
"weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("bei Ausfall erwartet 502, bekam %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Streifen in der Leiste heisst .apps und blendet seine rechten 18 Pixel
|
||||||
|
// mit einer Maske aus. Bekommt ein zweites Element dieselbe Klasse, erbt es die
|
||||||
|
// Maske — die Schublade sah rechts aus, als loese sie sich auf, und in der
|
||||||
|
// Geometrie war nichts zu sehen. Deshalb hier festgenagelt.
|
||||||
|
func TestNurDerStreifenHeisstApps(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("web/honeybar/honeybar.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := string(b)
|
||||||
|
|
||||||
|
for _, verboten := range []string{`class="menu apps"`, `class="apps menu"`} {
|
||||||
|
if strings.Contains(src, verboten) {
|
||||||
|
t.Errorf("%s: Das Menue erbt damit die Ausblendmaske des App-Streifens", verboten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Genau ein Element darf die Klasse tragen: der Streifen selbst.
|
||||||
|
if n := strings.Count(src, `class="apps"`); n != 1 {
|
||||||
|
t.Errorf(`class="apps" kommt %dx vor, erwartet genau einmal (der Streifen)`, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die HoneyBar bringt ihr CSS und ihr Markup in Vorlagenzeichenketten mit.
|
||||||
|
// Ein Backtick in einem Kommentar beendet die Zeichenkette mittendrin — die
|
||||||
|
// Datei ist dann kaputt, und zwar nur in der HoneyBar, nicht auf dem
|
||||||
|
// Schreibtisch. Genau das ist zweimal passiert; deshalb dieser Wächter.
|
||||||
|
func TestHoneyBarOhneBacktickInKommentaren(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("web/honeybar/honeybar.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := string(b)
|
||||||
|
|
||||||
|
imBlock := false
|
||||||
|
for nr, zeile := range strings.Split(src, "\n") {
|
||||||
|
rest := zeile
|
||||||
|
for {
|
||||||
|
if imBlock {
|
||||||
|
ende := strings.Index(rest, "*/")
|
||||||
|
if ende < 0 {
|
||||||
|
if strings.Contains(rest, "`") {
|
||||||
|
t.Errorf("Zeile %d: Backtick in einem Kommentar — das beendet die Vorlagenzeichenkette:\n %s", nr+1, strings.TrimSpace(zeile))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.Contains(rest[:ende], "`") {
|
||||||
|
t.Errorf("Zeile %d: Backtick in einem Kommentar — das beendet die Vorlagenzeichenkette:\n %s", nr+1, strings.TrimSpace(zeile))
|
||||||
|
}
|
||||||
|
rest = rest[ende+2:]
|
||||||
|
imBlock = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.Index(rest, "/*")
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rest = rest[start+2:]
|
||||||
|
imBlock = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Notiz ist eine Notiz, kein Dokument: Die Grenze muss der Server ziehen,
|
||||||
|
// nicht das Formular — sonst schreibt sie jeder Aufruf aus, der das Formular
|
||||||
|
// umgeht.
|
||||||
|
func TestNotizHatEineGrenze(t *testing.T) {
|
||||||
|
s := newTestServer(t, "")
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
put := func(text string) int {
|
||||||
|
body, _ := json.Marshal(map[string]string{"text": text})
|
||||||
|
req := httptest.NewRequest("PUT", "/api/note", strings.NewReader(string(body)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := put("# Einkauf\n- [ ] Milch"); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("kurze Notiz: %d, erwartet 204", code)
|
||||||
|
}
|
||||||
|
if code := put(strings.Repeat("x", 4001)); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("zu lange Notiz: %d, erwartet 400", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/note", nil))
|
||||||
|
var out map[string]string
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
if out["text"] != "# Einkauf\n- [ ] Milch" {
|
||||||
|
t.Errorf("zurück kam %q — die zu lange Notiz darf die gute nicht überschrieben haben", out["text"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fremde Notizen gehen niemanden etwas an: Der Schlüssel für die App-Liste darf
|
||||||
|
// sie nicht öffnen, auch nicht lesend.
|
||||||
|
func TestNotizNichtUeberDenSchluessel(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t, "honeyos")
|
||||||
|
s := newTestServer(t, idp.URL)
|
||||||
|
s.cfg.AppsKey = "geheim123"
|
||||||
|
s.auth = auth.New(s.cfg)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/note", nil)
|
||||||
|
req.Header.Set("X-HoneyOS-Key", "geheim123")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("mit Schlüssel: %d, erwartet 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Wappen steht an drei Stellen: als Datei (Favicon), als Modul für den
|
||||||
|
// Schreibtisch und als Zeile in der HoneyBar — die kann keine Datei nachladen,
|
||||||
|
// weil sie in fremden Apps läuft und deren CSP das entscheidet.
|
||||||
|
//
|
||||||
|
// Drei Kopien laufen auseinander, sobald jemand eine davon von Hand anfasst.
|
||||||
|
// Erzeugt werden alle von tools/biene.py; dieser Test hält sie zusammen.
|
||||||
|
func TestWappenIstUeberallDasselbe(t *testing.T) {
|
||||||
|
roh, err := os.ReadFile("web/img/biene.svg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web/img/biene.svg fehlt: %v", err)
|
||||||
|
}
|
||||||
|
wappen := strings.TrimSpace(string(roh))
|
||||||
|
if !strings.HasPrefix(wappen, "<svg") || len(wappen) < 500 {
|
||||||
|
t.Fatalf("web/img/biene.svg sieht nicht nach dem Wappen aus (%d Zeichen)", len(wappen))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, datei := range []string{"web/honeybar/honeybar.js", "web/js/biene.js"} {
|
||||||
|
b, err := os.ReadFile(datei)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", datei, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), wappen) {
|
||||||
|
t.Errorf("%s trägt ein anderes Wappen als web/img/biene.svg — tools/biene.py laufen lassen", datei)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die HoneyBar darf ihre Stile nicht als <style>-Element mitbringen: Markup
|
||||||
|
// faellt unter die CSP der fremden App, ueber die CSSOM gesetzte Stile nicht.
|
||||||
|
// Mit einem <style> muesste jede Gastgeber-App wieder 'unsafe-inline' erlauben,
|
||||||
|
// und ohne das stuende die HoneyBar dort schwarz auf schwarz — genau so war es
|
||||||
|
// bis zum 11.09.2026. Der Rueckfall fuer alte Browser baut das Element per
|
||||||
|
// createElement, nicht im Markup; deshalb sucht der Test nach dem Tag.
|
||||||
|
func TestHoneyBarBringtKeinStyleElementMit(t *testing.T) {
|
||||||
|
b, err := os.ReadFile("web/honeybar/honeybar.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := string(b)
|
||||||
|
|
||||||
|
if strings.Contains(src, "<style>") || strings.Contains(src, "<style ") {
|
||||||
|
t.Error("honeybar.js enthaelt ein <style>-Element im Markup — das braucht 'unsafe-inline' in der fremden App")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "adoptedStyleSheets") {
|
||||||
|
t.Error("honeybar.js setzt keine adoptedStyleSheets mehr — wie kommen die Stile in den Shadow DOM?")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user