Compare commits
13
Commits
b46b699576
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5b7f8e63 | ||
|
|
501c3b8930 | ||
|
|
3b47003492 | ||
|
|
8ca16aec84 | ||
|
|
ff413864a9 | ||
|
|
3f8b98b869 | ||
|
|
aec042a8c9 | ||
|
|
75df4e0833 | ||
|
|
e2988f7ee9 | ||
|
|
3807ddca24 | ||
|
|
c0abfbb491 | ||
|
|
b7cc06b7c1 | ||
|
|
981a2331df |
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
|
||||
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.
|
||||
Also reicht die HoneyBar die Frage als `?einstellungen=1` an den Schreibtisch
|
||||
weiter. Ein Klick, ein Seitenwechsel — dort, wo man angemeldet ist.
|
||||
Die HoneyBar hat die Frage deshalb als `?einstellungen=1` an den Schreibtisch
|
||||
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
|
||||
Einstellungen (Marcs Entscheidung, 29.08.2026). Aus fremden Apps war es ohnehin
|
||||
@@ -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,
|
||||
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
|
||||
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.
|
||||
Ein Widget ist jetzt eine **Beschreibung**, kein Stück HTML im Dokument:
|
||||
|
||||
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
|
||||
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).
|
||||
Der Rahmen (`web/js/widgets/rahmen.js`) macht daraus im Terminal-Modus ein
|
||||
Fenster in der Widgetspalte und im Desktop-Modus ein Schild, kennt die 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**
|
||||
und reißt weder den Schreibtisch noch die anderen Widgets mit.
|
||||
|
||||
**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.
|
||||
Umgestellt sind Benutzer, Wetter und der Systemzustand. Aus der `index.html`
|
||||
sind damit vier fest verdrahtete Kästen verschwunden, und aus dem Raster die
|
||||
Einzelplätze — vorher hätte das dritte Widget beides wieder angefasst.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**Was dabei offen gelassen wurde:** die dritte Form. Widgets sollen später auch
|
||||
als Aufklappfeld in der HoneyBar leben können, deshalb erzeugt `inhalt()` nur
|
||||
den Kern; die Verpackung entsteht im Rahmen. Und die Reihenfolge ist eine
|
||||
Liste — genau das, was „Widgets verschieben" braucht.
|
||||
|
||||
## 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)
|
||||
|
||||
**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem
|
||||
Schreibtisch, in der HoneyBar, beides oder keins. Die Stadt wird einmal gesucht
|
||||
**Steht.** Zwei Orte, unabhängig voneinander: auf dem Schreibtisch, in der
|
||||
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
|
||||
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,
|
||||
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
|
||||
muss deren CSP heute `style-src 'unsafe-inline'` erlauben, weil die HoneyBar ihre
|
||||
Stile im Shadow DOM mitbringt — ohne das erscheint sie schwarz auf schwarz.
|
||||
Gemessen am 28.08.2026 an einer nachgebauten fremden App.
|
||||
Fremde Apps mussten für die HoneyBar `style-src 'unsafe-inline'` erlauben, weil
|
||||
sie ihre Stile im Shadow DOM als `<style>` mitbrachte — ohne das erschien sie
|
||||
schwarz auf schwarz (gemessen am 28.08.2026).
|
||||
|
||||
**Auflösbar:** Die Stile als eigene Datei neben `honeybar.js` ausliefern und im
|
||||
Shadow DOM per `<link>` einbinden. Dann genügt `style-src 'self' https://honeyos…`,
|
||||
und die CSP der fremden App bleibt streng. Kostet eine zweite Anfrage, die
|
||||
zwischengespeichert wird.
|
||||
**Anders gelöst als hier geplant.** Der Plan war, die Stile als eigene Datei
|
||||
auszuliefern und per `<link>` einzubinden. Das hätte `'unsafe-inline'` gegen
|
||||
`style-src … https://honeyos…` getauscht — eine CSP-Änderung gegen eine andere,
|
||||
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
|
||||
selbst und der Innenabstand, den sie der Seite verpasst. Über die CSSOM gesetzte
|
||||
Stile (`el.style.x = …`) fallen nicht unter die CSP — was im Markup steht, schon.
|
||||
Der Strich zum Wiederausklappen hat seinen eigenen Shadow-Root und wäre sonst
|
||||
das einzige Stück ohne Farbe geblieben — er geht denselben Weg.
|
||||
|
||||
### 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
|
||||
**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
|
||||
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,
|
||||
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.
|
||||
Der Zettel darf **keine zweite Notiz-App** werden — kein Titel, keine Liste,
|
||||
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
|
||||
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)
|
||||
|
||||
**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
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
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.
|
||||
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
|
||||
geschenkt — sie fragen HoneyOS, nicht die App.
|
||||
@@ -445,6 +516,48 @@ geschenkt — sie fragen HoneyOS, nicht die App.
|
||||
|
||||
## 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
|
||||
liegen ohnehin schon dort, das ist eine Erweiterung, kein Neubau
|
||||
- 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
|
||||
hängen sich hinten an.
|
||||
|
||||
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit
|
||||
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
|
||||
zurück, nicht als kaputter Eintrag.
|
||||
## Kategorien
|
||||
|
||||
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
|
||||
|
||||
@@ -184,15 +221,177 @@ in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
|
||||
**Tasten:** `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
|
||||
`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
|
||||
|
||||
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
|
||||
HoneyBar, in beidem oder nirgends. Zwei getrennte Schalter unter
|
||||
*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim
|
||||
Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer
|
||||
Seite, die er selten aufruft.
|
||||
HoneyBar, in beidem oder nirgends. Zwei Orte, zwei Schalter 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 einen eigenen Schalter unter
|
||||
*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
|
||||
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
|
||||
# nicht „mehr erlaubt", sondern „beide müssen zustimmen".
|
||||
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.
|
||||
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
|
||||
App-Liste und in der Konsole steht ein CORS-Fehler.
|
||||
|
||||
**Warum `style-src 'unsafe-inline'`:** Die HoneyBar bringt ihre Stile mit, im
|
||||
Shadow DOM. Eine strenge CSP verbietet die — gemessen: die HoneyBar erscheint,
|
||||
aber schwarz auf schwarz, ohne Röhre. Das ist eine echte Abschwächung der
|
||||
CSP dieser App; viele fertige Werkzeuge brauchen `unsafe-inline` ohnehin für
|
||||
sich selbst, aber wissen sollte man es. (Auflösbar, indem die HoneyBar ihre Stile
|
||||
als eigene Datei ausliefert — steht in [IDEAS.md](IDEAS.md).)
|
||||
**Kein `style-src 'unsafe-inline'` nötig** (seit 11.09.2026). Die HoneyBar
|
||||
bringt ihre Stile zwar mit, hängt sie aber als konstruiertes Stylesheet
|
||||
(`adoptedStyleSheets`) in ihren Shadow DOM statt als `<style>`-Element. Nur
|
||||
Markup fällt unter die CSP, über die CSSOM gesetzte Stile nicht — die App
|
||||
braucht für die HoneyBar also weder `'unsafe-inline'` noch eine Freigabe für
|
||||
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)
|
||||
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 |
|
||||
|---|---|
|
||||
| Schreibtisch | Über HoneyOS |
|
||||
| Einstellungen … | Einstellungen |
|
||||
| HoneyBar einklappen | Terminal-/Desktop-Modus |
|
||||
| HoneyBar einklappen | Einstellungen |
|
||||
| — | Terminal-/Desktop-Modus |
|
||||
|
||||
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
|
||||
im Systemmenü, wo sie in jeder App auch stehen.
|
||||
Das Zahnrad, das dort früher stand, ist weg: Auf dem Schreibtisch stehen die
|
||||
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** —
|
||||
und das ist eine Grenze, keine Bequemlichkeit. Einstellungen schreiben, und
|
||||
dafür braucht der Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der
|
||||
Browser sie nicht mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in
|
||||
einem Cookie, das nur HoneyOS' eigene Seiten lesen dürfen. Also stellt die
|
||||
HoneyBar die Frage in der Adresse (`?einstellungen=1`), und der Schreibtisch
|
||||
beantwortet sie: Er macht dasselbe Feld auf, das auch `[S]` öffnet, und nimmt
|
||||
den Anhang danach wieder aus der Adresse.
|
||||
**In den Apps ist „Schreibtisch" ein Link, keine Handlung** — und das ist eine
|
||||
Grenze, keine Bequemlichkeit. Einstellungen schreiben, und dafür braucht der
|
||||
Server die HoneyOS-Sitzung: Aus einer fremden Site schickt der Browser sie nicht
|
||||
mit, und selbst unter einer Domain liegt das CSRF-Prüfwort in einem Cookie, das
|
||||
nur HoneyOS' eigene Seiten lesen dürfen.
|
||||
|
||||
Deshalb stand dort lange ein zweiter Punkt „Einstellungen …", der auf dieselbe
|
||||
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
|
||||
in der HoneyBar fremder Apps, nicht im Systemmenü, nicht im Startmenü. Es ist
|
||||
|
||||
+174
-2
@@ -3,14 +3,18 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
URL string `json:"url"`
|
||||
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 {
|
||||
Name string `json:"name,omitempty"`
|
||||
Picture string `json:"picture,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 {
|
||||
@@ -52,6 +74,9 @@ const (
|
||||
maxApps = 24
|
||||
maxSettings = 40
|
||||
maxFieldLen = 200
|
||||
// Eine Notiz, kein Dokument. Wer mehr braucht, hat InkNote — und der Server
|
||||
// muss die Grenze durchsetzen, nicht das Formular.
|
||||
maxNoteLen = 4000
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -76,6 +101,10 @@ func Open(path string) (*Store, error) {
|
||||
if s.d.Apps == nil {
|
||||
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 {
|
||||
s.d.Users = map[string]*User{}
|
||||
}
|
||||
@@ -119,6 +148,7 @@ func (s *Store) SetApps(apps []App) error {
|
||||
a.Subtitle = strings.TrimSpace(a.Subtitle)
|
||||
a.URL = strings.TrimSpace(a.URL)
|
||||
a.Glyph = strings.TrimSpace(a.Glyph)
|
||||
a.Kategorie = strings.TrimSpace(a.Kategorie)
|
||||
|
||||
if !idPattern.MatchString(a.ID) {
|
||||
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] {
|
||||
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 {
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
s.d.Apps = cleaned
|
||||
s.d.Apps = sortiereNachKategorie(cleaned)
|
||||
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 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -182,6 +246,27 @@ func (s *Store) SeenUser(sub, name, picture string) error {
|
||||
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 {
|
||||
if len(in) > maxSettings {
|
||||
return fmt.Errorf("zu viele Einstellungen")
|
||||
@@ -254,5 +339,92 @@ func (s *Store) AppOrigins() map[string]bool {
|
||||
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.
|
||||
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
|
||||
// Dienst für unsere Testläufe.
|
||||
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()
|
||||
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") {
|
||||
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
|
||||
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"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
|
||||
// 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.
|
||||
const Version = "1.1"
|
||||
const Version = "1.3"
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
@@ -146,6 +147,19 @@ func (s *Server) routes() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
|
||||
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("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
||||
// 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
|
||||
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
|
||||
// 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) {
|
||||
set := s.store.SettingsFor(sub)
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -284,14 +301,11 @@ body {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
/* Ohne feste Plätze verteilt das Raster die drei Bereiche der Reihe nach —
|
||||
das Wetter landete dann neben „system" und schob die Kacheln in die
|
||||
schmale linke Spalte. Links stehen die Anzeigen untereinander, rechts
|
||||
gehören die Apps, über die volle Höhe. */
|
||||
.win-user { grid-column: 1; grid-row: 1; }
|
||||
.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; }
|
||||
/* Zwei Bereiche, keine Einzelplätze mehr: Links die Widgetspalte, rechts
|
||||
die Apps. Vorher hatte jedes Widget seinen festen Platz im Raster — beim
|
||||
dritten hätte man das Raster wieder anfassen müssen. */
|
||||
.widgetspalte { grid-column: 1; }
|
||||
.tiles { grid-column: 2; }
|
||||
}
|
||||
|
||||
/* Fenster */
|
||||
@@ -493,6 +507,62 @@ body {
|
||||
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 button {
|
||||
background: none;
|
||||
@@ -788,15 +858,9 @@ body {
|
||||
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. */
|
||||
.field-out {
|
||||
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 { width: 100%; color: var(--p-mid); margin-top: 10px; }
|
||||
.btn-out:hover { color: var(--bg); background: var(--p); }
|
||||
.startmenu .sm-head {
|
||||
padding: 4px 8px;
|
||||
@@ -880,8 +944,26 @@ body {
|
||||
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 {
|
||||
margin-top: 26px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -1028,9 +1110,7 @@ body {
|
||||
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
|
||||
@@ -1101,16 +1181,205 @@ body {
|
||||
.uw-name { color: var(--p); font-size: 15px; }
|
||||
.uw-gruss { color: var(--p-mid); }
|
||||
.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
|
||||
Systemfenster. Drei verschieden breite Kästen untereinander sehen aus wie ein
|
||||
Fehler, nicht wie Absicht. Muss hinter den Regeln stehen, die es aufhebt —
|
||||
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. */
|
||||
.deskwidgets {
|
||||
@@ -1127,3 +1396,84 @@ body {
|
||||
@media (max-width: 620px) {
|
||||
.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; }
|
||||
+102
-29
@@ -98,10 +98,44 @@
|
||||
"display:block",
|
||||
"color-scheme:dark",
|
||||
].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" });
|
||||
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
const STIL = `
|
||||
:host, * { box-sizing: border-box; }
|
||||
/* Dieselben Röhren wie auf dem Schreibtisch. Welche gilt, sagt der
|
||||
Server; bis seine Antwort da ist, leuchtet Bernstein. */
|
||||
@@ -136,16 +170,20 @@
|
||||
}
|
||||
.home { letter-spacing: .14em; color: var(--p); padding-left: 2px; }
|
||||
.home b { color: var(--p-mid); font-weight: 400; }
|
||||
/* Dasselbe Quadrat wie in der Menüleiste des Schreibtischs: dort ist es
|
||||
klein und leicht angehoben. Ein Zeichen in voller Größe sah daneben
|
||||
wie ein anderes Logo aus. */
|
||||
.home .hex { font-size: 10px; vertical-align: 2px; }
|
||||
/* Dasselbe Wappen wie in der Menüleiste des Schreibtischs, in derselben
|
||||
Größe. Es bleibt gelb, während der Rest die Röhre wechselt: Ein
|
||||
Zeichen, das die Farbe mitwechselt, ist ein Symbol — eines, das seine
|
||||
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[aria-expanded="true"] { border-color: var(--p-dim); background: var(--p-faint); }
|
||||
/* Bei vielen Apps schiebt man den Streifen seitwärts. Ohne
|
||||
flex-shrink:0 an den Knöpfen quetscht das Raster sie stattdessen
|
||||
zusammen, bis nur noch Buchstabenreste dastehen — am Handy sofort.
|
||||
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 {
|
||||
display: flex; gap: 4px; flex: 1 1 auto; min-width: 0;
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
@@ -198,7 +236,11 @@
|
||||
aufgemacht, weil man etwas sucht — also wird gelesen, nicht nur
|
||||
wiedererkannt. Deshalb stehen hier immer Namen, anders als im
|
||||
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
|
||||
auseinanderdruecken, und die Schublade wird breiter als der Schirm. */
|
||||
.drawer { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; }
|
||||
@@ -234,14 +276,24 @@
|
||||
padding: 4px 8px; font-size: 10px; letter-spacing: .18em;
|
||||
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
|
||||
Logo links, das App-Menue am Pfeil rechts. */
|
||||
.menu.sys { left: 6px; right: auto; }
|
||||
@media (max-width: 560px) { .clock { display: none; } }
|
||||
</style>
|
||||
`;
|
||||
|
||||
root.innerHTML = `
|
||||
<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>
|
||||
<div class="right">
|
||||
<span class="wx" id="wx" hidden></span>
|
||||
@@ -253,16 +305,17 @@
|
||||
<div class="menu sys" id="sysmenu" role="menu">
|
||||
<div class="head">honeyos</div>
|
||||
<a id="mHome" role="menuitem">Schreibtisch</a>
|
||||
<a id="mSettings" role="menuitem">Einstellungen …</a>
|
||||
<hr />
|
||||
<button id="hide" role="menuitem">HoneyBar einklappen</button>
|
||||
</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="drawer" id="menuApps"></div>
|
||||
</div>`;
|
||||
|
||||
stileAnlegen(root, STIL);
|
||||
|
||||
/* ── Aussehen ─────────────────────────────────────────────────────────────
|
||||
* Dieselben drei Röhren wie in style.css. Sie hier zu wiederholen ist die
|
||||
* bewusste Entscheidung: die HoneyBar soll ohne zweite Datei auskommen, damit
|
||||
@@ -311,6 +364,13 @@
|
||||
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) => {
|
||||
if (app.id === "honeyos") return; // steckt schon links im Logo
|
||||
|
||||
@@ -325,6 +385,17 @@
|
||||
if (app.id === current) a.setAttribute("aria-current", "page");
|
||||
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");
|
||||
m.className = "d-app";
|
||||
m.href = app.url;
|
||||
@@ -463,25 +534,25 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Die zwei Wege ins System. Beides sind Links auf den Schreibtisch, keine
|
||||
* Aktionen hier — und das ist keine Bequemlichkeit, sondern die Grenze:
|
||||
* Der eine Weg ins System: ein Link auf den Schreibtisch, keine Aktion hier.
|
||||
*
|
||||
* Einstellungen **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung,
|
||||
* und die schickt der Browser aus einer fremden App nicht mit; liegt alles
|
||||
* unter einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in
|
||||
* einem Cookie der HoneyOS-Adresse, das dieses Skript von hier aus nicht
|
||||
* lesen darf.
|
||||
* Das ist keine Bequemlichkeit, sondern die Grenze: Einstellungen
|
||||
* **schreiben**. Dafuer braucht der Server die HoneyOS-Sitzung, und die
|
||||
* schickt der Browser aus einer fremden App nicht mit; liegt alles unter
|
||||
* einer Domain, kommt sie zwar mit, aber das CSRF-Prüfwort steht in einem
|
||||
* 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
|
||||
* angemeldet hat. Wer es aus einer App heraus braucht, geht ueber
|
||||
* „Einstellungen …" — dort unten steht der Knopf.
|
||||
* angemeldet hat — unten in den Einstellungen.
|
||||
*/
|
||||
function systemZiele() {
|
||||
const heim = new URL(homeUrl, location.href);
|
||||
root.getElementById("mHome").href = heim.href;
|
||||
const einst = new URL(homeUrl, location.href);
|
||||
einst.search = "einstellungen=1";
|
||||
root.getElementById("mSettings").href = einst.href;
|
||||
root.getElementById("mHome").href = new URL(homeUrl, location.href).href;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,8 +577,11 @@
|
||||
// auch wenn in HoneyOS Grün eingestellt war. Er gehört zur HoneyBar, also
|
||||
// trägt er dieselbe Röhre.
|
||||
const t = ROEHREN[(settings && settings.phosphor) || "amber"] || ROEHREN.amber;
|
||||
r.innerHTML = `
|
||||
<style>
|
||||
// Auch hier ueber die CSSOM statt als style-Element: Der Strich hat seinen
|
||||
// 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 {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, ${t.dim}, ${t.p} 50%, ${t.dim});
|
||||
@@ -515,8 +589,7 @@
|
||||
transition: opacity .15s, height .15s;
|
||||
}
|
||||
:host(:hover) .strich { opacity: 1; height: 3px; }
|
||||
</style>
|
||||
<div class="strich"></div>`;
|
||||
`);
|
||||
|
||||
nub.addEventListener("click", () => {
|
||||
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="theme-color" content="#0a0705" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="crt" id="screen">
|
||||
<!-- ── Boot ───────────────────────────────────────────────────────── -->
|
||||
<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>
|
||||
<p class="boot-hint">[ beliebige Taste = überspringen ]</p>
|
||||
</div>
|
||||
@@ -20,6 +21,9 @@
|
||||
<!-- ── Anmeldung ──────────────────────────────────────────────────── -->
|
||||
<div class="login" id="login" hidden>
|
||||
<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">
|
||||
<button class="login-btn" id="btnLogin">▸ <span id="loginLabel">Anmelden</span></button>
|
||||
<p class="login-note" id="loginNote"></p>
|
||||
@@ -33,7 +37,7 @@
|
||||
<!-- Das Logo ist das Systemmenü — dasselbe wie in der HoneyBar
|
||||
fremder Apps, damit HoneyOS von überall gleich erreichbar ist. -->
|
||||
<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>
|
||||
<nav class="mb-apps" id="mbApps" aria-label="Anwendungen"></nav>
|
||||
<div class="mb-right">
|
||||
@@ -47,30 +51,10 @@
|
||||
</header>
|
||||
|
||||
<main class="stage" id="stage">
|
||||
<!-- Statusfenster -->
|
||||
<section class="win win-status">
|
||||
<div class="win-bar"><span class="win-title">system</span><span class="win-dots">●●●</span></div>
|
||||
<div class="win-body">
|
||||
<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 -->
|
||||
<!-- Links die Widgets untereinander, rechts die Apps. Was hier steht,
|
||||
entscheidet der Widget-Rahmen (js/widgets/rahmen.js) — im
|
||||
Dokument steht nur der Platz dafür. -->
|
||||
<div class="widgetspalte" id="widgetSpalte"></div>
|
||||
<section class="tiles" id="tiles" aria-label="Apps starten"></section>
|
||||
</main>
|
||||
|
||||
@@ -78,11 +62,8 @@
|
||||
<div class="deskspace" id="deskspace" hidden>
|
||||
<div class="desk-icons" id="deskIcons"></div>
|
||||
<div class="windows" id="windows"></div>
|
||||
<!-- Dasselbe Wetter, im Schreibtisch-Modus als kleines Feld oben rechts -->
|
||||
<div class="deskwidgets">
|
||||
<div class="deskwidget" id="userDesk" hidden></div>
|
||||
<div class="deskwidget" id="wxDesk" hidden></div>
|
||||
</div>
|
||||
<!-- Dieselben Widgets, im Schreibtisch-Modus als Schilder oben rechts -->
|
||||
<div class="deskwidgets" id="deskwidgets"></div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
// Symbole im App-Streifen beider Leisten. Im Aufklappmenü stehen sie immer.
|
||||
barGlyphs: "on",
|
||||
// Wetter. Zwei Schalter statt einem: Der Schreibtisch und die HoneyBar sind
|
||||
// zwei verschiedene Orte, und wer das Wetter beim Arbeiten in einer App will,
|
||||
// will es nicht zwingend auch auf einer Seite, die er selten aufruft.
|
||||
// Wetter in der HoneyBar. Auf dem Schreibtisch entscheidet das die Widget-
|
||||
// Liste — zwei Schalter für dasselbe Widget wären einer zu viel.
|
||||
weatherBar: "off",
|
||||
weatherDesk: "off",
|
||||
weatherCity: "",
|
||||
weatherLat: "",
|
||||
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 = {
|
||||
@@ -53,7 +54,9 @@ export async function api(path, opts = {}) {
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||
if (opts.method && opts.method !== "GET") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
// 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");
|
||||
if (tok) headers["X-CSRF-Token"] = tok;
|
||||
}
|
||||
@@ -120,3 +123,33 @@ export function applySettings() {
|
||||
r.dataset.glow = state.settings.glow;
|
||||
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 {
|
||||
$, 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,
|
||||
SS_BOOTED, state,
|
||||
} from "./core.js";
|
||||
import { GLYPHS, glyphSvg } from "./glyphs.js";
|
||||
import { renderUser } from "./widgets/user.js";
|
||||
import { loadWeather, renderWeather } from "./widgets/weather.js";
|
||||
import { BIENE_ASCII, BIENE_SVG } from "./biene.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.
|
||||
@@ -67,6 +80,9 @@ function runBoot() {
|
||||
return new Promise((resolve) => {
|
||||
const log = $("bootLog");
|
||||
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;
|
||||
const lines = bootLines();
|
||||
let i = 0;
|
||||
@@ -110,14 +126,20 @@ function runBoot() {
|
||||
|
||||
function renderShell() {
|
||||
$("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();
|
||||
renderMode();
|
||||
tickClock();
|
||||
// Nicht abgewartet: Das Wetter kommt von außen und darf den Aufbau des
|
||||
// Schreibtischs nicht verzögern. Danach alle 10 Minuten, im selben Takt,
|
||||
// in dem der Server seinen Zwischenspeicher erneuert.
|
||||
loadWeather();
|
||||
setInterval(loadWeather, 10 * 60 * 1000);
|
||||
// Nicht abgewartet: Die Widgets holen ihre Daten von außen und dürfen den
|
||||
// Aufbau des Schreibtischs nicht verzögern. Den Takt fürs Nachladen kennt
|
||||
// jedes Widget selbst.
|
||||
widgetsLaden().then(renderWeatherBar);
|
||||
widgetsStarten();
|
||||
}
|
||||
|
||||
function renderMenubarApps() {
|
||||
@@ -163,9 +185,7 @@ function renderMode() {
|
||||
closeAllWindows();
|
||||
renderTiles();
|
||||
}
|
||||
renderStats();
|
||||
renderUser();
|
||||
renderWeather();
|
||||
widgetsZeichnen();
|
||||
paintProbes();
|
||||
}
|
||||
|
||||
@@ -188,18 +208,13 @@ function launch(app) {
|
||||
|
||||
/** Die Kennzahlen stehen in beiden Ansichten — deshalb über die Klasse in
|
||||
* 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() {
|
||||
const up = Object.values(state.probes).filter((s) => s === "up").length;
|
||||
const rows = [
|
||||
["apps", `${state.cfg.apps.length}`],
|
||||
["erreichbar", `${up}/${state.cfg.apps.length}`],
|
||||
["modus", state.settings.mode],
|
||||
["röhre", state.settings.phosphor],
|
||||
["version", `v${state.cfg.version}`],
|
||||
];
|
||||
const html = rows.map(([k, v]) => `<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`).join("");
|
||||
document.querySelectorAll(".statlist").forEach((dl) => (dl.innerHTML = html));
|
||||
|
||||
widgetsZeichnen();
|
||||
document.querySelectorAll("#windows .statlist").forEach((dl) => {
|
||||
dl.outerHTML = statsHtml();
|
||||
});
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
@@ -270,7 +285,7 @@ function renderTiles() {
|
||||
const wrap = $("tiles");
|
||||
wrap.innerHTML = "";
|
||||
|
||||
state.cfg.apps.forEach((app) => {
|
||||
const kachel = (app) => {
|
||||
const a = document.createElement("a");
|
||||
a.className = "tile";
|
||||
a.href = app.url;
|
||||
@@ -285,7 +300,25 @@ function renderTiles() {
|
||||
e.preventDefault();
|
||||
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");
|
||||
@@ -490,13 +523,18 @@ function openSystemWindow() {
|
||||
id: "system",
|
||||
title: "system",
|
||||
w: 320,
|
||||
body: `<p class="greet"></p><dl class="statlist"></dl>`,
|
||||
onMount: renderStats,
|
||||
// Derselbe Inhalt wie das Widget im Terminal-Modus — eine Quelle, damit
|
||||
// nicht zwei Listen mit denselben Zahlen auseinanderlaufen.
|
||||
body: statsHtml(),
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
(a) => `<button data-launch="${a.id}">
|
||||
<span class="al-glyph">${glyphSvg(a.glyph)}</span>
|
||||
@@ -504,6 +542,8 @@ function openProgramsWindow() {
|
||||
<span class="al-sub">${escapeHtml(a.subtitle || "")}</span>
|
||||
</button>`
|
||||
)
|
||||
.join("")
|
||||
)
|
||||
.join("");
|
||||
|
||||
openWindow({
|
||||
@@ -544,75 +584,72 @@ function closeModal() {
|
||||
|
||||
function openSettings() {
|
||||
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) =>
|
||||
`<div class="seg" data-seg="${name}">` +
|
||||
opts.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${cur === v}">${l}</button>`).join("") +
|
||||
`</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(
|
||||
"settings",
|
||||
"einstellungen",
|
||||
`
|
||||
<div class="field">
|
||||
<span class="label">Betriebsart</span>
|
||||
${seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Röhre</span>
|
||||
${seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Bildröhren-Effekte</span>
|
||||
${seg("scanlines", [["on", "scanlines an"], ["off", "aus"]], s.scanlines)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("flicker", [["on", "flackern an"], ["off", "aus"]], s.flicker)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("glow", [["on", "glühen an"], ["off", "aus"]], s.glow)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">HoneyBar</span>
|
||||
${seg("barGlyphs", [["on", "symbole an"], ["off", "nur namen"]], s.barGlyphs)}
|
||||
<p class="ae-hint" style="margin-left:0">gilt für den App-Streifen hier und in jeder App</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Apps öffnen</span>
|
||||
${seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Wetter</span>
|
||||
${seg("weatherDesk", [["on", "auf dem schreibtisch"], ["off", "aus"]], s.weatherDesk)}
|
||||
<div style="height:6px"></div>
|
||||
${seg("weatherBar", [["on", "in der honeybar"], ["off", "aus"]], s.weatherBar)}
|
||||
<div class="wx-city-row">
|
||||
sektion(
|
||||
"Darstellung",
|
||||
zeile("Betriebsart", seg("mode", [["terminal", "terminal"], ["desktop", "desktop"]], s.mode)) +
|
||||
zeile("Röhre", seg("phosphor", [["amber", "amber"], ["green", "grün"], ["ice", "eis"]], s.phosphor)) +
|
||||
schalter("scanlines", "Scanlines") +
|
||||
schalter("flicker", "Flackern") +
|
||||
schalter("glow", "Glühen")
|
||||
) +
|
||||
sektion(
|
||||
"HoneyBar",
|
||||
zeile("Symbole im App-Streifen", seg("barGlyphs", [["on", "an"], ["off", "nur namen"]], s.barGlyphs)) +
|
||||
zeile("Apps öffnen", seg("openIn", [["tab", "neuer tab"], ["same", "gleiches fenster"]], s.openIn)) +
|
||||
schalter("weatherBar", "Wetter in der Leiste") +
|
||||
`<p class="sek-hinweis">Gilt auch für die HoneyBar in deinen Apps — es ist dieselbe Leiste.</p>`
|
||||
) +
|
||||
sektion(
|
||||
"Widgets",
|
||||
`<ul class="wl" id="wlListe"></ul>
|
||||
<p class="sek-hinweis">Reihenfolge und An/Aus liegen bei deinem Konto, nicht am Gerät.</p>`
|
||||
) +
|
||||
sektion(
|
||||
"Bild",
|
||||
bildFormularHtml() +
|
||||
`<p class="sek-hinweis">Genau ein Bild je Konto; ein neues ersetzt das alte.
|
||||
Zu sehen ist es im Widget „bild".</p>`
|
||||
) +
|
||||
sektion(
|
||||
"Wetter",
|
||||
`<div class="wx-city-row">
|
||||
<input id="wxQuery" type="text" placeholder="Stadt suchen …" autocomplete="off" />
|
||||
<button class="btn" id="btnWxSearch">suchen</button>
|
||||
</div>
|
||||
<div id="wxResults" class="wx-results"></div>
|
||||
<p class="ae-hint" id="wxCurrent" style="margin-left:0"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Programme</span>
|
||||
<button class="btn" id="btnApps">${state.cfg.apps.length} eingetragen — verwalten …</button>
|
||||
</div>
|
||||
|
||||
<p class="login-note" style="margin-top:4px">
|
||||
Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.
|
||||
</p>` +
|
||||
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus einer
|
||||
// fremden App heraus nicht anklicken können soll (siehe honeybar.js), und
|
||||
// von HoneyOS aus findet man es dort, wo alles andere auch steht.
|
||||
(state.oidc
|
||||
? `<div class="field field-out">
|
||||
<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>
|
||||
</div>`
|
||||
: ""),
|
||||
<p class="sek-hinweis" id="wxCurrent"></p>`
|
||||
) +
|
||||
sektion(
|
||||
"Programme",
|
||||
zeile("Eingetragene Apps", `<button class="btn" id="btnApps">${state.cfg.apps.length} — verwalten …</button>`)
|
||||
) +
|
||||
sektion(
|
||||
"Konto",
|
||||
`<p class="sek-hinweis">Diese Einstellungen liegen auf dem Server, nicht im Browser — auf jedem
|
||||
Gerät, an dem du angemeldet bist, sieht HoneyOS gleich aus.</p>` +
|
||||
// Abmelden steht hier und nur hier: Es ist das Einzige, was man aus
|
||||
// einer fremden App heraus nicht anklicken können soll (siehe
|
||||
// honeybar.js), und von HoneyOS aus findet man es dort, wo alles
|
||||
// andere auch steht.
|
||||
(state.oidc ? `<button class="btn btn-out" id="btnLogout">⏻ Abmelden</button>` : "")
|
||||
),
|
||||
(root) => {
|
||||
root.querySelectorAll(".seg").forEach((segEl) => {
|
||||
segEl.addEventListener("click", (e) => {
|
||||
@@ -637,6 +674,9 @@ function openSettings() {
|
||||
});
|
||||
});
|
||||
|
||||
widgetListeZeichnen(root.querySelector("#wlListe"));
|
||||
bildFormularMontieren(root);
|
||||
|
||||
const zeigeOrt = () => {
|
||||
root.querySelector("#wxCurrent").textContent = 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 ────────────────────────────────────────────────────────
|
||||
* Bearbeitet wird eine Kopie der Liste; erst „speichern" schickt sie als
|
||||
* Ganzes zum Server. Der prüft sie noch einmal und antwortet mit dem, was er
|
||||
* wirklich behalten hat — das ist danach der Stand, nicht das Formular.
|
||||
*//* 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() {
|
||||
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,
|
||||
// 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 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) => `
|
||||
<details class="approw" ${a.__offen ? "open" : ""}>
|
||||
<summary>
|
||||
<span class="ae-glyph">${glyphSvg(a.glyph)}</span>
|
||||
<span>${escapeHtml(a.name || a.id || "neue app")}</span>
|
||||
<span class="ae-url">${escapeHtml(a.url || "")}</span>
|
||||
<span class="ae-move">
|
||||
<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>
|
||||
${pfeile(i, a.name || a.id)}
|
||||
</summary>
|
||||
<div class="ae-body">
|
||||
<label class="ae-field"><span>ID</span>
|
||||
@@ -740,35 +887,55 @@ function openApps() {
|
||||
|
||||
const render = (root) => {
|
||||
const list = root.querySelector("#appList");
|
||||
list.innerHTML = draft.length
|
||||
? draft.map(rowHtml).join("")
|
||||
list.innerHTML = zeilen.length
|
||||
? 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>`;
|
||||
|
||||
list.querySelectorAll("input[data-f], select[data-f]").forEach((inp) => {
|
||||
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) =>
|
||||
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);
|
||||
})
|
||||
);
|
||||
|
||||
// Merken, was offen ist — das Umsortieren zeichnet die Liste neu, und ohne
|
||||
// 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", () => {
|
||||
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
|
||||
// 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) => {
|
||||
if (j < 0 || j >= draft.length) return;
|
||||
[draft[i], draft[j]] = [draft[j], draft[i]];
|
||||
if (j < 0 || j >= zeilen.length) return;
|
||||
[zeilen[i], zeilen[j]] = [zeilen[j], zeilen[i]];
|
||||
render(root);
|
||||
// Zurück auf denselben Knopf, damit man mehrmals hintereinander drücken
|
||||
// kann, ohne die Maus nachzuführen.
|
||||
@@ -792,6 +959,7 @@ function openApps() {
|
||||
`<div class="apped"><div id="appList"></div>
|
||||
<div class="modal-actions">
|
||||
<span class="saveline" id="appMsg"></span>
|
||||
<button class="btn" id="btnKat">+ kategorie</button>
|
||||
<button class="btn" id="btnAdd">+ neue app</button>
|
||||
<button class="btn btn-primary" id="btnSaveApps">speichern</button>
|
||||
</div>
|
||||
@@ -801,19 +969,26 @@ function openApps() {
|
||||
|
||||
root.querySelector("#btnAdd").addEventListener("click", () => {
|
||||
// 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);
|
||||
});
|
||||
|
||||
root.querySelector("#btnKat").addEventListener("click", () => {
|
||||
zeilen.push({ typ: KOPF, name: "" });
|
||||
render(root);
|
||||
[...root.querySelectorAll(".ae-kat")].pop()?.focus();
|
||||
});
|
||||
|
||||
root.querySelector("#btnSaveApps").addEventListener("click", async () => {
|
||||
const msg = root.querySelector("#appMsg");
|
||||
msg.dataset.kind = "";
|
||||
msg.textContent = "speichere …";
|
||||
try {
|
||||
const clean = draft.map(({ __offen, ...rest }) => rest);
|
||||
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(clean) })).apps;
|
||||
const offen = new Set(draft.filter((a) => a.__offen).map((a) => a.id));
|
||||
draft = state.cfg.apps.map((a) => ({ ...a, __offen: offen.has(a.id) }));
|
||||
state.cfg.apps = (await api("/api/apps", { method: "PUT", body: JSON.stringify(appsAus(zeilen)) })).apps;
|
||||
const offen = new Set(zeilen.filter((z) => z.__offen).map((z) => z.id));
|
||||
zeilen = zeilenAus(state.cfg.apps).map((z) =>
|
||||
z.typ === KOPF ? z : { ...z, __offen: offen.has(z.id) }
|
||||
);
|
||||
msg.textContent = `${state.cfg.apps.length} gespeichert.`;
|
||||
render(root);
|
||||
renderMenubarApps();
|
||||
@@ -881,7 +1056,11 @@ function buildAppMenu() {
|
||||
`<div class="sm-head">wechseln zu</div>` +
|
||||
(state.cfg.apps.length
|
||||
? `<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(
|
||||
(a) =>
|
||||
`<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>` : ""}
|
||||
</button>`
|
||||
)
|
||||
.join("")
|
||||
)
|
||||
.join("") +
|
||||
`</div>`
|
||||
: `<button data-settings="1">keine app eingetragen …</button>`);
|
||||
@@ -1145,15 +1326,6 @@ async function main() {
|
||||
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) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
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 { widgetAnmelden } from "./rahmen.js";
|
||||
|
||||
function greeting() {
|
||||
const h = new Date().getHours();
|
||||
@@ -15,8 +16,13 @@ function greeting() {
|
||||
* 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
|
||||
* 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
|
||||
? `<img class="uw-bild" src="${escapeAttr(state.me.picture)}" alt="" />`
|
||||
: `<span class="uw-bild uw-leer">${glyphSvg("chip")}</span>`;
|
||||
@@ -24,21 +30,10 @@ function userHtml() {
|
||||
<div class="uw">
|
||||
${bild}
|
||||
<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-host">${state.cfg.user}@${state.cfg.hostname}</span>
|
||||
<span class="uw-host">${escapeHtml(state.cfg.user)}@${escapeHtml(state.cfg.hostname)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUser() {
|
||||
const tile = $("userTile");
|
||||
const desk = $("userDesk");
|
||||
const desktopMode = state.settings.mode === "desktop";
|
||||
const html = userHtml();
|
||||
|
||||
tile.hidden = desktopMode;
|
||||
desk.hidden = !desktopMode;
|
||||
tile.querySelector(".user-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
},
|
||||
});
|
||||
+44
-28
@@ -1,5 +1,6 @@
|
||||
import { $, api, state } from "../core.js";
|
||||
import { api, $, state } from "../core.js";
|
||||
import { glyphSvg } from "../glyphs.js";
|
||||
import { widgetAnmelden, widgetsLaden } from "./rahmen.js";
|
||||
|
||||
/* ── Wetter ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
@@ -7,21 +8,36 @@ import { glyphSvg } from "../glyphs.js";
|
||||
* was er hergibt. Kein Ort eingestellt oder Dienst nicht erreichbar: 204, und
|
||||
* das Widget bleibt weg. Eine Zahl von gestern wäre schlechter als keine.
|
||||
*/
|
||||
export async function loadWeather() {
|
||||
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) {
|
||||
state.weather = null;
|
||||
renderWeather();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
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 `
|
||||
<div class="wx">
|
||||
<span class="wx-glyph">${glyphSvg(w.glyph)}</span>
|
||||
@@ -31,28 +47,22 @@ function weatherHtml(w) {
|
||||
<span class="wx-city">${w.ort}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function renderWeather() {
|
||||
const w = state.weather;
|
||||
const tile = $("wxTile");
|
||||
const desk = $("wxDesk");
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 desktopMode = state.settings.mode === "desktop";
|
||||
|
||||
// Das Widget: Fenster im Terminal-Modus, Schild auf dem Schreibtisch.
|
||||
const alsWidget = state.settings.weatherDesk === "on" && w;
|
||||
tile.hidden = !alsWidget || desktopMode;
|
||||
desk.hidden = !alsWidget || !desktopMode;
|
||||
if (alsWidget) {
|
||||
const html = weatherHtml(w);
|
||||
tile.querySelector(".wx-body").innerHTML = html;
|
||||
desk.innerHTML = html;
|
||||
}
|
||||
|
||||
// Und in der HoneyBar. Die Menüleiste hier ist zwar anderer Code als
|
||||
// honeybar.js in den Apps, aber für den, der davorsitzt, dieselbe HoneyBar —
|
||||
// ein Schalter, der nur woanders wirkt, wäre nicht zu erklären.
|
||||
if (!bar) return;
|
||||
const w = state.weather;
|
||||
const inBar = state.settings.weatherBar === "on" && w;
|
||||
bar.hidden = !inBar;
|
||||
if (inBar) {
|
||||
@@ -60,3 +70,9 @@ export function renderWeather() {
|
||||
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();
|
||||
}
|
||||
+140
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"honeyos/internal/auth"
|
||||
"honeyos/internal/store"
|
||||
"honeyos/internal/weather"
|
||||
)
|
||||
@@ -46,6 +47,49 @@ func TestMitOrtKommtDasWetter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -85,3 +129,99 @@ func TestHoneyBarOhneBacktickInKommentaren(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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