Compare commits
76 Commits
08d5da969d
...
setup-matr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366a563b1f | ||
|
|
88627711be | ||
|
|
9927d57755 | ||
|
|
fb3c7a99f1 | ||
|
|
072fc9b9f9 | ||
|
|
47d73a466b | ||
|
|
205cb1d948 | ||
|
|
8ebee64543 | ||
|
|
b0bcc8abbd | ||
|
|
57b39f8771 | ||
|
|
6cea1d9de9 | ||
|
|
0a72e63e84 | ||
|
|
1cdd944aca | ||
|
|
df0a5d5035 | ||
|
|
ae775c4dcf | ||
|
|
14505c2296 | ||
|
|
9572b6116a | ||
|
|
4c7ef500a1 | ||
|
|
d40329ae7c | ||
|
|
fb540813ff | ||
|
|
705374cacd | ||
|
|
cde4035eb3 | ||
|
|
4bb07122ac | ||
|
|
718d843df2 | ||
|
|
60ed7d624e | ||
|
|
0d4377aed2 | ||
|
|
e663534000 | ||
|
|
c79f856e31 | ||
|
|
0a044bbb92 | ||
|
|
6ff401ad74 | ||
|
|
32bed2d65e | ||
|
|
223f5abf78 | ||
|
|
28bf2c5dfe | ||
|
|
b3c1fbc9bd | ||
|
|
76b7a96673 | ||
|
|
995a397b2e | ||
|
|
4cb0e89e92 | ||
|
|
e603bdc3be | ||
|
|
d4f6e56ff0 | ||
|
|
ddca0ae831 | ||
|
|
ae116f52f9 | ||
|
|
8946cbecf6 | ||
|
|
e9e589ddcc | ||
|
|
2af7e65d4a | ||
|
|
fddb2e3544 | ||
|
|
d62224366b | ||
|
|
5b252f17fd | ||
|
|
c3ac1be083 | ||
|
|
e98130d274 | ||
|
|
10ad368214 | ||
|
|
ea52f223cd | ||
|
|
29b3b623eb | ||
|
|
f0674cda37 | ||
|
|
8f2b4faad9 | ||
|
|
2125601e56 | ||
|
|
470220b8f4 | ||
|
|
0c3838da8a | ||
|
|
325c30414c | ||
|
|
1ecbcc38ae | ||
|
|
81ddb781ca | ||
|
|
75b8a66c62 | ||
|
|
6bb915299f | ||
|
|
271612af32 | ||
|
|
c0680700a3 | ||
|
|
bf404e34c3 | ||
|
|
c132cd372d | ||
|
|
036196abe1 | ||
|
|
22411b4cc3 | ||
|
|
13e1f35ae0 | ||
|
|
7ae5007fb5 | ||
|
|
50d162af57 | ||
|
|
9254e9d631 | ||
|
|
6d9dca95f0 | ||
|
|
e04632a3e1 | ||
|
|
0f35698eb8 | ||
|
|
28f5654060 |
59
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Build & Push Docker Image to Gitea Registry
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
REGISTRY: git.mischlabs.de
|
||||
REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/mischlabs
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI
|
||||
shell: sh
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker CLI not found. Installing static binary..."
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
else
|
||||
echo "Neither curl nor wget found! Attempting package manager install..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache docker-cli
|
||||
else
|
||||
echo "Cannot install Docker CLI!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Docker CLI already installed."
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Log in to Gitea Package Registry
|
||||
shell: sh
|
||||
run: |
|
||||
if [ -z "${{ secrets.REGISTRY_USER }}" ] || [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "Missing registry credentials."
|
||||
echo "Create repository secrets REGISTRY_USER and REGISTRY_TOKEN."
|
||||
echo "REGISTRY_TOKEN must be a Gitea access token with package write permission."
|
||||
exit 1
|
||||
fi
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
shell: sh
|
||||
run: |
|
||||
docker build -t ${{ env.REGISTRY_IMAGE }}:latest -t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} .
|
||||
docker push ${{ env.REGISTRY_IMAGE }}:latest
|
||||
docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||
144
Brain.md
@@ -3,11 +3,15 @@
|
||||
## Projektziel
|
||||
Eine ansprechende Landing Page fuer mischlabs.de, die alle Dienste als Kacheln mit direkten Verlinkungen anzeigt. Laeuft als Docker-Container und wird ueber Git auto-deployed.
|
||||
|
||||
Aktueller Stand: Die Seite ist keine reine Linkliste mehr, sondern ein kompaktes Service-Dashboard mit Kategorien, Suche, Hintergrund-Statuscheck und PWA-Unterstuetzung.
|
||||
|
||||
## Status
|
||||
- [x] Git-Repo initialisiert und Remote verbunden (https://git.mischlabs.de/MrDiderot/mischlabs.git)
|
||||
- [x] Dienste-Liste vom Screenshot erfasst (9 Services inkl. MiChess)
|
||||
- [x] Landing Page Design und Implementierung (index.html + style.css)
|
||||
- [x] Docker-Setup (Dockerfile, docker-compose.yml, nginx.conf)
|
||||
- [x] PWA-Grundlage fuer iOS/Home-Screen-Installation
|
||||
- [x] Dashboard v2 mit Kategorien, Suche und Live-Statusindikatoren
|
||||
- [x] Push zum Remote
|
||||
- [x] MiChess-Kachel hinzugefuegt (Commit lokal, Push steht aus wg. Auth-Fehler)
|
||||
- [ ] Push des MiChess-Commits (Git-Auth-Token erneuern)
|
||||
@@ -18,27 +22,30 @@ Eine ansprechende Landing Page fuer mischlabs.de, die alle Dienste als Kacheln m
|
||||
- Subdomain: auth.mischlabs.de
|
||||
- Siehe auth/Brain.md fuer Details
|
||||
|
||||
## Dienste-Uebersicht (9 Stueck)
|
||||
## Dienste-Uebersicht (8 Stueck)
|
||||
| # | Subdomain | Beschreibung | Farbe/Accent |
|
||||
|---|-----------|-------------|--------------|
|
||||
| 1 | drive.mischlabs.de | Cloud-Speicher | #4285f4 (Blau) |
|
||||
| 2 | git.mischlabs.de | Code-Repositories | #f05033 (Rot) |
|
||||
| 3 | movies.mischlabs.de | Filme & Serien | #a855f7 (Lila) |
|
||||
| 4 | audiobook.mischlabs.de | Hoerbuecher | #22c55e (Gruen) |
|
||||
| 5 | foodlabs.mischlabs.de | Rezepte | #f97316 (Orange) |
|
||||
| 6 | password.mischlabs.de | Passwort-Manager | #eab308 (Gelb) |
|
||||
| 7 | docs.mischlabs.de | Dokumentation | #06b6d4 (Cyan) |
|
||||
| 8 | michess.mischlabs.de | Schach | #10b981 (Gruen) |
|
||||
| 9 | tom.mischlabs.de | Persoenliche Seite | #ec4899 (Pink) |
|
||||
| 5 | password.mischlabs.de | Passwort-Manager | #eab308 (Gelb) |
|
||||
| 6 | docs.mischlabs.de | Dokumentation | #06b6d4 (Cyan) |
|
||||
| 7 | michess.mischlabs.de | Schach | #10b981 (Gruen) |
|
||||
| 8 | tom.mischlabs.de | Persoenliche Seite | #ec4899 (Pink) |
|
||||
|
||||
## Technische Entscheidungen
|
||||
- **Reines HTML + CSS** (kein Framework, kein JS) - maximale Performance, minimales Docker-Image
|
||||
- **Reines HTML + CSS mit minimalem Dashboard-/PWA-JS** (kein Framework) - maximale Performance, minimales Docker-Image
|
||||
- **nginx:alpine** als Base-Image - extrem leichtgewichtig (~5MB)
|
||||
- **Google Fonts (Inter)** fuer moderne Typografie
|
||||
- **Inline SVG Icons** - keine externen Dependencies
|
||||
- **CSS Grid** mit `auto-fill` + `minmax(240px, 1fr)` fuer responsives Layout
|
||||
- **Dark Theme** mit subtilen Glow-Effekten
|
||||
- **Staggered Animations**: Kacheln faden nacheinander ein (9 Stueck)
|
||||
- **PWA**: Manifest, Apple-Touch-Icon, App-Icons und Service Worker fuer installierbare iOS/Web-App
|
||||
- **Dashboard**: Kategorien `Zugang`, `Medien`, `Arbeit`, `Persoenlich`, Suche und clientseitige no-cors Statuschecks
|
||||
- **Auth-Link**: Zeigt auf die Keycloak Account Console (`/realms/mischlabs/account`), nicht mehr auf die Admin-Konsole
|
||||
- **Adminseite**: `/admin.html` nutzt Keycloak Authorization Code + PKCE fuer Login und bietet Editor/Statusdiagnose fuer Dienste
|
||||
|
||||
## Docker-Setup
|
||||
- **Container-Name**: `mischlabs`
|
||||
@@ -52,6 +59,14 @@ Eine ansprechende Landing Page fuer mischlabs.de, die alle Dienste als Kacheln m
|
||||
mischlabs/
|
||||
index.html - Haupt-HTML mit allen 9 Service-Kacheln
|
||||
style.css - Komplettes Styling (Dark Theme, Grid, Animationen)
|
||||
app.js - Suche, Filter, Statuschecks, Service-Worker-Registrierung
|
||||
admin.html - SSO-geschuetzte Adminoberflaeche
|
||||
admin.js - Keycloak PKCE Login, Editor, Statusdiagnose
|
||||
services.json - Zentrale Service-/Kategorie-Konfiguration
|
||||
offline.html - Offline-Fallback fuer installierte PWA
|
||||
manifest.webmanifest - PWA-Metadaten fuer installierbare App
|
||||
sw.js - Schlanker Service Worker mit App-Shell-Cache
|
||||
icons/ - App-Icons fuer iOS/PWA
|
||||
Dockerfile - nginx:alpine mit kopierten Static Files
|
||||
docker-compose.yml - Container-Definition (Name: mischlabs, Port 8085)
|
||||
nginx.conf - Server-Konfiguration, Caching, Security-Headers
|
||||
@@ -73,3 +88,118 @@ mischlabs/
|
||||
- Git-Push-Fehler aufgetreten (Authentication failed) - Token muss erneuert werden
|
||||
- DNS: Alte A-Records (185.181.104.242) von INWX wurden geloescht, nur noch Tunnel-Eintraege
|
||||
- NAS hat kein git installiert - entweder Git Server Paket installieren oder wget nutzen
|
||||
|
||||
## PWA-Stand 2026-05-21
|
||||
- MischLabs wurde als PWA vorbereitet.
|
||||
- iOS-relevante Meta-Tags in `index.html`:
|
||||
- `apple-mobile-web-app-capable`
|
||||
- `apple-mobile-web-app-status-bar-style`
|
||||
- `apple-mobile-web-app-title`
|
||||
- `apple-touch-icon`
|
||||
- Manifest:
|
||||
- `name`: MischLabs
|
||||
- `display`: standalone
|
||||
- `theme_color`: #10b981
|
||||
- `background_color`: #0a0a0f
|
||||
- Icons:
|
||||
- `icons/app-icon-source.png` (1024x1024, quadratischer Ausschnitt aus dem MischLabs-Bild)
|
||||
- `icons/apple-touch-icon-v2.png` (180x180, frischer Dateiname gegen iOS-Icon-Cache)
|
||||
- `icons/apple-touch-icon.png` (180x180, alte Kompatibilitaetskopie)
|
||||
- `icons/icon-192.png` (192x192)
|
||||
- `icons/icon-512.png` (512x512)
|
||||
- `icons/maskable-512.png` (512x512, maskable)
|
||||
- Service Worker:
|
||||
- Cache-Name: `mischlabs-pwa-v4`
|
||||
- Cached die App-Shell (`/`, `index.html`, `offline.html`, `style.css?v=10`, `app.js?v=2`, `services.json?v=1`, Manifest und Icons)
|
||||
- Navigationen fallen offline auf `offline.html` zurueck
|
||||
- Dockerfile kopiert Manifest, Service Worker und Icons in das Nginx-Image.
|
||||
- `nginx.conf` liefert `manifest.webmanifest` mit `application/manifest+json` und `sw.js` mit harten No-Cache-Headern aus.
|
||||
|
||||
## Dashboard-v2-Stand 2026-05-21
|
||||
- Neues Logo oben nutzt `icons/app-icon-source.png`.
|
||||
- Die alte oeffentliche Auth-Kachel wurde auf die Keycloak Account Console umgebogen:
|
||||
`https://auth.mischlabs.de/realms/mischlabs/account`
|
||||
- Kategorien:
|
||||
- Zugang: SSO, Drive, Passwords
|
||||
- Medien: Movies, Audiobooks, Books
|
||||
- Arbeit: Git, MiChess
|
||||
- Persoenlich: Tom
|
||||
- `app.js` prueft Dienste im Hintergrund per `fetch(..., { mode: "no-cors" })`. Das ist bewusst nur ein Erreichbarkeitsindikator, kein vollwertiges Monitoring.
|
||||
|
||||
## Adminseite Stand 2026-05-21
|
||||
- URL: `https://mischlabs.de/admin.html`
|
||||
- SSO-Client in Keycloak benoetigt:
|
||||
- Client ID: `mischlabs-admin`
|
||||
- Client type: OpenID Connect
|
||||
- Client authentication: Off/Public Client
|
||||
- Standard flow: On
|
||||
- Valid redirect URI: `https://mischlabs.de/admin.html`
|
||||
- Valid post logout redirect URI: `https://mischlabs.de/admin.html`
|
||||
- Web origin: `https://mischlabs.de`
|
||||
- Scopes: `openid email profile`
|
||||
- Erlaubter Admin in der statischen UI:
|
||||
- `preferred_username=mrdiderot` oder `email=mail.misch@pm.me`
|
||||
- Aktuelle Grenze: Weil mischlabs noch rein statisch ueber Nginx laeuft, kann `/admin.html` globale Aenderungen noch nicht serverseitig speichern. Der Editor speichert lokal im Browser (`localStorage`) und kann eine neue `services.json` exportieren. Fuer echte Live-Aenderungen braucht der naechste Schritt ein kleines Backend oder einen Gitea-Commit-Workflow.
|
||||
- Statusdiagnose nutzt Browser-Fetch mit `mode: "no-cors"`. Dadurch erkennt sie Erreichbarkeit/Timeouts, aber bei fremden Subdomains keine echten HTTP-Statuscodes.
|
||||
- Wenn der Browser die Pruefung wegen CORS/CORB blockiert (`Failed to fetch`), wird der Dienst **nicht** mehr als offline markiert, sondern als "Nicht im Browser pruefbar". Das betraf z. B. Vaultwarden/`password.mischlabs.de`.
|
||||
- Fuer echte Fehlerdetails braucht es ein kleines Backend bzw. einen Statusproxy, der serverseitig `HEAD`/`GET` prueft.
|
||||
|
||||
## Statusproxy Stand 2026-05-21
|
||||
- `mischlabs` wurde von Nginx auf einen kleinen Node-Server (`server.js`) umgestellt.
|
||||
- Der Node-Server serviert die statischen Dateien weiter und stellt `/api/status` bereit.
|
||||
- `/api/status` liefert:
|
||||
- Docker-Container via `/var/run/docker.sock`
|
||||
- Watchtower-Logs und letzten Watchtower-Lauf
|
||||
- Speicherplatz fuer `/volume1` und `/volume2`, im Container gemountet als `/host/volume1` und `/host/volume2`
|
||||
- Serverseitige Service-Checks mit HTTP-Status und Latenz
|
||||
- NAS-Compose fuer `mischlabs` braucht diese Mounts:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /volume1:/host/volume1:ro
|
||||
- /volume2:/host/volume2:ro
|
||||
```
|
||||
|
||||
- Aktuelle NAS-Werte bei Planung:
|
||||
- `/volume1`: 3.7T total, 2.5T used, 1.2T free, 68%
|
||||
- `/volume2`: 104G total, 88G used, 12G free, 89%
|
||||
- Watchtower: `Scanned=23`, `Updated=0`, `Failed=0` beim manuellen Lauf um 2026-05-21 14:16 UTC
|
||||
|
||||
## Globale KI-Systemeinstellung (WICHTIG!)
|
||||
> [!IMPORTANT]
|
||||
> **Arbeitsanweisung für alle KI-Systeme (LLM / Agenten):**
|
||||
> Nach dem erfolgreichen Abschluss einer Aufgabe oder eines Meilensteins MUSS automatisch ein strukturierter Eintrag in dieser `Brain.md` vorgenommen werden. Dieser Eintrag soll dokumentieren:
|
||||
> 1. Welche Änderungen an welchen Dateien vorgenommen wurden (inklusive Begründung).
|
||||
> 2. Welche neuen Features, Endpoints oder Design-Anpassungen integriert wurden.
|
||||
> 3. Der aktuelle Stand des Deployments und eventuelle offene Punkte oder nächste Schritte.
|
||||
> Dies stellt sicher, dass alle nachfolgenden KI-Systeme und der Benutzer stets einen konsistenten, lückenlosen Projektverlauf nachvollziehen können.
|
||||
|
||||
## Letzte Änderungen & Meilensteine (Stand: 2026-05-21)
|
||||
- **Erweitertes Admin-Tool (Universal Admin Panel):**
|
||||
- **Docker Container Logs Modal:** Integration eines interaktiven, terminal-ähnlichen Log-Overlays in `admin.html` und `admin.js`. Jeder Container auf der Übersicht sowie Watchtower bieten nun einen **Logs**-Button. Logs können dynamisch mit Tiefenbeschränkungen (`50`, `150`, `300`, `500` Zeilen) abgefragt und manuell aktualisiert werden. Backend-Endpunkt: GET `/api/containers/:name/logs`.
|
||||
- **Live Service-Inspektion (`🔍`):** Status-Kacheln der Dienste bieten ein Lupensymbol. Klicks darauf starten eine serverseitige Node-Anfrage (CORS-frei) an die Zieladresse und geben Antwortlatenz, HTTP-Statuscode, vollständige HTTP-Header und eine Vorschau der ersten 1000 Zeichen der Antwort zurück. Backend-Endpunkt: GET `/api/services/inspect`.
|
||||
- **Interaktiver NAS Verzeichnis-Scanner:** Hinzufügen eines Ordnergrößen-Scanners im NAS-Speicher-Panel. Berechnet rekursiv Ordnergrößen für gemountete Volumes (`/host/volume1`, `/host/volume2`), sortiert sie absteigend und erlaubt durch Anklicken von Unterordnern ein direktes Weiterscannen im entsprechenden Pfad. Backend-Endpunkt: GET `/api/disks/scan-folder`.
|
||||
- **Premium Design-Branding:**
|
||||
- **Textlogo mit Farbverlauf:** Das "MischLabs"-Textlogo (`.brand h1`) auf dem Dashboard (`index.html`) und im Adminbereich (`admin.html`) erstrahlt nun in einem modernen, dynamischen Farbverlauf von Blau (`#38bdf8`) zu Lila/Flieder (`#c084fc`) unter Verwendung von Text-Maskierungen.
|
||||
- **Cache-Buster-Update:** Cache-Busting-Parameter in den CSS-Verknüpfungen wurden auf `v=26` erhöht, damit der geänderte Farbverlauf sofort ohne manuelles Cache-Leeren im Browser gerendert wird.
|
||||
- **Git Deployment & Sync:**
|
||||
- Sämtliche Implementierungen wurden sicher staged, committet und direkt auf das selbstgehostete Gitea-Remote (`https://git.mischlabs.de/MrDiderot/mischlabs.git`, Branch `master`) gepusht.
|
||||
- **Frontend Optimierung & Mobile Usability (Stand: 2026-05-22):**
|
||||
- **Frontend Optimierung & Mobile Usability:**
|
||||
- **PWA-Kachel Entfernung:** Die ungenutzte "PWA Installierbar"-Statistik-Kachel wurde aus der oberen Übersichtsleiste (`index.html`) entfernt. Das Layout der verbleibenden zwei Kacheln ("Online" & "Dienste") wurde in `style.css` auf ein stabiles, zentriertes 2-Spalten-Raster für Desktop und mobile Ansichten angepasst.
|
||||
- **Kompakte Service-Kategoriefilter:** Die Kategorieauswahl (`.segments`) wurde aus der klebrigen (`sticky`) oberen `.control-bar` herausgelöst und in einem separaten `#categoryFilters`-Bereich direkt über dem Dienste-Grid platziert. Sie verbraucht dadurch im ungescrollten Zustand keinen kostbaren Platz mehr auf Mobilgeräten.
|
||||
- **Visual-Premium Redesign & Untertitel:** Die Filter-Pills wurden verkleinert (Höhe von 36px auf 28px reduziert, kleinere Schriftgröße `0.78rem` und kompakteres Padding), linksbündig ausgerichtet und mit sanften Hover- & Aktiv-Transitions versehen. Zudem wurde der fälschlicherweise hinzugefügte dunkle Hintergrundbalken auf der `.control-bar` vollständig entfernt, um den nahtlosen Backdrop-Blur-Effekt über dem globalen Farbverlauf wiederherzustellen. Der Untertitel der Landing Page wurde von *"Cloud, Medien, Code..."* auf den prägnanten Namen *"Die Selfhost-Werkstatt"* abgeändert.
|
||||
- **Favoriten-Zähler statt Online-Status:** Die "Online"-Statistik-Kachel in der Kopfzeile wurde durch eine dynamische "Favoriten"-Statistik-Kachel ersetzt, um direkt die Anzahl der geladenen Lesezeichen anzuzeigen. Die Reihenfolge wurde auf "Dienste" zuerst und "Favoriten" an zweiter Stelle geändert. Das Erreichbarkeits-Monitoring im Hintergrund läuft weiterhin stabil, um die Status-Punkte der einzelnen Dienste-Karten aktuell zu halten.
|
||||
- **Service Worker API-Bypass & PWA Update:** Der Service-Worker (`sw.js`) ignoriert und reicht nun alle API-Anfragen unter `/api/` (z.B. `/api/favorites`, `/api/status`) direkt an das Netzwerk durch, anstatt sie lokal zu cachen. Dadurch werden neu angelegte Favoriten beim einfachen Navigieren auf das Dashboard sofort und ohne manuelles Strg+F5-Neuladen geladen.
|
||||
- **Automatisches SSO-Login im Admin Panel:** `/admin.html` prüft nun beim Laden, ob bereits eine aktive Sitzung vorliegt. Falls nicht und kein Fehler vorliegt, wird der Benutzer automatisch an den Keycloak SSO Login (`login()`) weitergeleitet. Dadurch entfällt der zusätzliche Anmelde-Klick komplett. Zum Schutz vor unendlichen Weiterleitungsschleifen nach einer Abmeldung wurde eine temporäre Logout-Erkennung via `sessionStorage` implementiert. Der Cache-Buster für `admin.js` wurde in `admin.html` auf `v=29` erhöht.
|
||||
- **Cache-Buster & PWA Cache Update:** Die Cache-Busting-Parameter für `style.css` und `app.js` wurden in `index.html` auf `v=33` bzw. `v=40` hochgestuft. Im Service-Worker (`sw.js`) wurde der Cache-Name auf `mischlabs-pwa-v26` erhöht und die App-Shell-Liste entsprechend angepasst, um ein sofortiges Update der App bei allen Nutzern ohne manuelles Cache-Leeren zu garantieren.
|
||||
- **Header User Badge & Fix CSS Specificity:**
|
||||
- **Behebung des CSS-Spezifitätskonflikts:** Ein globaler CSS-Reset `[hidden] { display: none !important; }` wurde am Anfang von `style.css` hinzugefügt. Zuvor hat die Spezifität von `.ghost-button { display: inline-flex; }` die Standard-Browserregel für das `hidden`-Attribut überschrieben, was dazu führte, dass sowohl der Login- als auch der Logout-Button fälschlicherweise gleichzeitig dauerhaft angezeigt wurden.
|
||||
- **Premium Header-User-Badge:** In der Kopfzeile von `admin.html` wurde ein neues Element `<span class="admin-user-badge" id="headerUserBadge" hidden></span>` implementiert, welches bei einer erfolgreichen Authentifizierung in `admin.js` mit dem Text `"Angemeldet als [Nutzername]"` befüllt und stilvoll eingeblendet wird. Der Badge wurde mit einem eleganten Glassmorphismus, dezenter Randlinie und einem 👤-Emoji modern und hochwertig designt.
|
||||
- **Cache-Buster & PWA Cache Update:** Die Cache-Busting-Parameter für `style.css` wurden in `index.html`, `admin.html` und `favorites.html` auf `v=34` hochgestuft. Im Service-Worker (`sw.js`) wurde der Cache-Name auf `mischlabs-pwa-v27` erhöht, um ein sofortiges Update der App-Shell im Browser aller Anwender zu erzwingen. In `admin.html` wurde der Script-Buster von `admin.js` auf `v=30` erhöht.
|
||||
- **Projekt Matrix & WhatsApp-Integration (Phase 1 & 2) (Stand: 2026-05-22):**
|
||||
- **Matrix-Infrastruktur (Synapse + Element Web):** Ein sicherer, datenbankgestützter (PostgreSQL 16) Matrix Homeserver (Synapse) unter `matrix.mischlabs.de` und ein Element-Web-Client unter `chat.mischlabs.de` wurden erfolgreich als Docker-Compose-Infrastruktur im Verzeichnis `D:\Vibecoding\Website\Matrix` aufgesetzt. SSO über Keycloak (`auth.mischlabs.de`) wurde nahtlos integriert. Native `.well-known`-Delegierung wurde im Node.js-Backend der Haupt-Landingpage implementiert und deployed.
|
||||
- **WhatsApp-Bridge (mautrix-whatsapp):** Integration der Bridge als Synapse-Appservice. Generierung sicherer 256-Bit AS/HS-Token und Hinterlegung in `data/whatsapp-registration.yaml`.
|
||||
- **Automatisierungs-Skript:** Erstellung von `configure-whatsapp.sh`, welches nach dem ersten Bridge-Start die Standard-`config.yaml` auf dem NAS vollautomatisch anpasst (SQLite-Datenbank, Port-Bindung `0.0.0.0`, Adressierung im Docker-Netzwerk und Sicherheits-Token).
|
||||
- **Code-Repository-Sync:** Alle geänderten Konfigurationsdateien, Docker-Erweiterungen und Hilfsskripte wurden sicher committet und in das neue Repository [Matrix.git](https://git.mischlabs.de/MrDiderot/Matrix.git) auf Branch `main` gepusht.
|
||||
|
||||
30
Dockerfile
@@ -1,7 +1,29 @@
|
||||
FROM nginx:alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY style.css /usr/share/nginx/html/
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.js /app/server.js
|
||||
COPY index.html /app/public/
|
||||
COPY favorites.html /app/public/
|
||||
COPY admin.html /app/public/
|
||||
COPY offline.html /app/public/
|
||||
COPY style.css /app/public/
|
||||
COPY app.js /app/public/
|
||||
COPY auth.js /app/public/
|
||||
COPY favorites.js /app/public/
|
||||
COPY admin.js /app/public/
|
||||
COPY services.json /app/public/
|
||||
COPY manifest.webmanifest /app/public/
|
||||
COPY sw.js /app/public/
|
||||
COPY favicon.ico /app/public/
|
||||
COPY icons/ /app/public/icons/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=80
|
||||
ENV FAVORITES_FILE=/app/data/favorites.json
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
249
admin.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MischLabs Admin</title>
|
||||
<meta name="description" content="MischLabs dashboard administration.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=4">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=4">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
|
||||
<link rel="stylesheet" href="/style.css?v=35">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero admin-hero">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>MischLabs</h1>
|
||||
<p class="subtitle">Dienste pruefen, Kategorien vorbereiten und Dashboard-Konfiguration verwalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions">
|
||||
<a class="ghost-button" id="headerDashboardBtn" href="/">Dashboard</a>
|
||||
<button class="ghost-button" id="loginButton" type="button">Mit Keycloak anmelden</button>
|
||||
<button class="ghost-button" id="logoutButton" type="button" hidden>Abmelden</button>
|
||||
<span class="admin-user-badge" id="headerUserBadge" hidden></span>
|
||||
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" aria-label="Menü öffnen" type="button" style="display: none;">
|
||||
<span class="hamburger-bar"></span>
|
||||
<span class="hamburger-bar"></span>
|
||||
<span class="hamburger-bar"></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell admin-shell">
|
||||
<section class="admin-panel" id="loginPanel">
|
||||
<p class="section-kicker">SSO</p>
|
||||
<h2>Anmeldung erforderlich</h2>
|
||||
<p class="admin-copy">Diese Seite nutzt Keycloak per Authorization Code + PKCE. Der Client muss in Keycloak als Public Client mit Redirect URI `https://mischlabs.de/admin.html` angelegt sein.</p>
|
||||
<div class="notice" id="loginMessage">Noch nicht angemeldet.</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" id="adminPanel" hidden>
|
||||
<div class="admin-panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">Konfiguration</p>
|
||||
<h2>System-Administration</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="adminUser" style="display: none;"></div>
|
||||
|
||||
<!-- Dashboard Overview Tab -->
|
||||
<div class="admin-tab-content" id="card-overview">
|
||||
<div class="overview-grid">
|
||||
<div class="overview-mini-card" data-target-tab="services">
|
||||
<div class="mini-card-head">
|
||||
<span class="mini-card-icon">🌐</span>
|
||||
<h4>Dienste-Status</h4>
|
||||
</div>
|
||||
<div class="mini-card-body" id="mini-services-status">Lade...</div>
|
||||
</div>
|
||||
<div class="overview-mini-card" data-target-tab="disks">
|
||||
<div class="mini-card-head">
|
||||
<span class="mini-card-icon">💾</span>
|
||||
<h4>NAS Speicher</h4>
|
||||
</div>
|
||||
<div class="mini-card-body" id="mini-disks-status">Lade...</div>
|
||||
</div>
|
||||
<div class="overview-mini-card" data-target-tab="watchtower">
|
||||
<div class="mini-card-head">
|
||||
<span class="mini-card-icon">🐳</span>
|
||||
<h4>Watchtower</h4>
|
||||
</div>
|
||||
<div class="mini-card-body" id="mini-watchtower-status">Lade...</div>
|
||||
</div>
|
||||
<div class="overview-mini-card" data-target-tab="containers">
|
||||
<div class="mini-card-head">
|
||||
<span class="mini-card-icon">📦</span>
|
||||
<h4>Container</h4>
|
||||
</div>
|
||||
<div class="mini-card-body" id="mini-containers-status">Lade...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-grid" id="opsGrid">
|
||||
<section class="ops-card ops-card-wide admin-tab-content" id="card-services">
|
||||
<div class="ops-card-head">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>Dienste-Status (Live-Proxy)</span>
|
||||
<span id="servicesStatusSummary" class="section-count" style="display: inline; margin-left: 8px; font-weight: 500;"></span>
|
||||
</div>
|
||||
<button class="ghost-button mini" id="refreshServicesButton" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="servicesStatusBoard" class="services-status-board">Lade Status...</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-card admin-tab-content" id="card-disks">
|
||||
<div class="ops-card-head">
|
||||
<span>NAS Speicher</span>
|
||||
<button class="ghost-button mini" id="refreshOpsButton" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
<div id="diskStatus" class="ops-list">Noch nicht geladen.</div>
|
||||
|
||||
<div class="disk-scanner-tool" style="margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border);">
|
||||
<h4 style="margin-bottom: 8px; color: #fff; font-size: 1.05rem; font-weight: 700;">Verzeichnis-Größen-Scanner</h4>
|
||||
<p style="font-size: 0.85rem; color: var(--dim); margin-bottom: 14px; line-height: 1.4;">
|
||||
Scanne Ordnergrößen auf den Host-Volumes (z.B. <code>/host/volume2</code> oder <code>/host/volume1/docker</code>), um Speicherfresser zu finden.
|
||||
</p>
|
||||
<div class="scanner-input-group" style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<input type="text" id="scannerPathInput" value="/host/volume2" placeholder="/host/volume2" style="flex-grow: 1; padding: 10px 14px; background: rgba(0, 0, 0, 0.2); border: 1px solid var(--border); border-radius: var(--radius); color: #fff; font-family: monospace; font-size: 0.9rem;">
|
||||
<button class="ghost-button mini" id="scannerStartBtn" type="button" style="padding-inline: 16px;">Scan starten</button>
|
||||
</div>
|
||||
<div id="scannerResults" class="ops-list" style="max-height: 350px; overflow-y: auto;" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-card admin-tab-content" id="card-watchtower">
|
||||
<div class="ops-card-head">
|
||||
<span>Watchtower</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="ghost-button mini" id="runWatchtowerButton" type="button">Jetzt pruefen</button>
|
||||
<button class="ghost-button mini" id="viewWatchtowerLogsBtn" type="button">Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="watchtowerStatus" class="ops-list">Noch nicht geladen.</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-card ops-card-wide admin-tab-content" id="card-containers">
|
||||
<div class="ops-card-head">
|
||||
<span>Container</span>
|
||||
<button class="ghost-button mini danger-button" id="dangerModeButton" type="button">Danger entsperren</button>
|
||||
</div>
|
||||
<div class="danger-state" id="dangerState" hidden></div>
|
||||
<div id="containerStatus" class="ops-list">Noch nicht geladen.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="ops-card ops-card-wide admin-tab-content" id="editorCard" style="margin-top: 14px;">
|
||||
<div class="ops-card-head">
|
||||
<span>Dienste & Kategorien (Konfiguration)</span>
|
||||
</div>
|
||||
<div class="admin-actions-bar" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;">
|
||||
<button class="ghost-button mini" id="checkAllButton" type="button">Alle Status pruefen</button>
|
||||
<button class="ghost-button mini" id="addServiceButton" type="button">Neuen Dienst hinzufügen</button>
|
||||
<button class="ghost-button mini" id="saveServerButton" type="button" style="background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.35); color: #38bdf8;">Auf Server speichern</button>
|
||||
<button class="ghost-button mini" id="resetLocalButton" type="button">Aenderungen verwerfen</button>
|
||||
<button class="ghost-button mini" id="downloadConfigButton" type="button">JSON exportieren</button>
|
||||
</div>
|
||||
<div id="editorFeedback" class="notice" style="display: none; margin-bottom: 16px;"></div>
|
||||
<div class="admin-table" id="serviceEditor" style="display: flex; flex-direction: column; gap: 16px;"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Hamburger Navigation Drawer -->
|
||||
<div class="admin-nav-backdrop" id="adminNavBackdrop"></div>
|
||||
<aside class="admin-nav-drawer" id="adminNavDrawer">
|
||||
<div class="drawer-header">
|
||||
<h3>MischLabs Admin</h3>
|
||||
<button class="drawer-close-btn" id="drawerCloseBtn" aria-label="Menü schließen" type="button">×</button>
|
||||
</div>
|
||||
<nav class="drawer-nav">
|
||||
<button class="drawer-nav-item" data-tab="overview" type="button">
|
||||
<span class="drawer-nav-icon">📊</span>
|
||||
<span class="drawer-nav-text">Übersicht</span>
|
||||
</button>
|
||||
<button class="drawer-nav-item" data-tab="services" type="button">
|
||||
<span class="drawer-nav-icon">🌐</span>
|
||||
<span class="drawer-nav-text">Dienste-Status</span>
|
||||
</button>
|
||||
<button class="drawer-nav-item" data-tab="disks" type="button">
|
||||
<span class="drawer-nav-icon">💾</span>
|
||||
<span class="drawer-nav-text">NAS Speicher</span>
|
||||
</button>
|
||||
<button class="drawer-nav-item" data-tab="watchtower" type="button">
|
||||
<span class="drawer-nav-icon">🐳</span>
|
||||
<span class="drawer-nav-text">Watchtower</span>
|
||||
</button>
|
||||
<button class="drawer-nav-item" data-tab="containers" type="button">
|
||||
<span class="drawer-nav-icon">📦</span>
|
||||
<span class="drawer-nav-text">Container</span>
|
||||
</button>
|
||||
<button class="drawer-nav-item" data-tab="config" type="button">
|
||||
<span class="drawer-nav-icon">⚙️</span>
|
||||
<span class="drawer-nav-text">Dienste & Kategorien</span>
|
||||
</button>
|
||||
|
||||
<div class="drawer-divider"></div>
|
||||
|
||||
<a class="drawer-nav-item" href="/">
|
||||
<span class="drawer-nav-icon">🏠</span>
|
||||
<span class="drawer-nav-text">Dashboard</span>
|
||||
</a>
|
||||
<button class="drawer-nav-item drawer-logout-btn" id="drawerLogoutBtn" type="button">
|
||||
<span class="drawer-nav-icon">🚪</span>
|
||||
<span class="drawer-nav-text">Abmelden</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Container Logs Modal Overlay -->
|
||||
<div class="modal-overlay" id="logsModalOverlay" hidden>
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="logsModalTitle">Container Logs</h3>
|
||||
<button class="modal-close-btn" id="logsModalCloseBtn" type="button">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="terminal-logs-controls" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 0.85rem;">
|
||||
<div>
|
||||
<label for="logsTailSelect" style="color: var(--dim); margin-right: 6px;">Zeilen:</label>
|
||||
<select id="logsTailSelect" style="background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); color: #fff; padding: 4px 8px; cursor: pointer;">
|
||||
<option value="50">50</option>
|
||||
<option value="150" selected>150</option>
|
||||
<option value="300">300</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="ghost-button mini" id="logsModalRefreshBtn" type="button">Aktualisieren</button>
|
||||
</div>
|
||||
<pre class="terminal-logs" id="logsModalConsole">Lade Logs...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Details Modal Overlay -->
|
||||
<div class="modal-overlay" id="serviceModalOverlay" hidden>
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="serviceModalTitle">Dienst-Inspektion</h3>
|
||||
<button class="modal-close-btn" id="serviceModalCloseBtn" type="button">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="serviceModalContent">
|
||||
<!-- Dynamically populated details -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/admin.js?v=31" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
669
app.js
Normal file
@@ -0,0 +1,669 @@
|
||||
const CONFIG_URL = '/services.json?v=1';
|
||||
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
|
||||
|
||||
const icons = {
|
||||
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><path d="M9 12l2 2 4-4"></path>',
|
||||
folder: '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path><circle cx="12" cy="16" r="1"></circle>',
|
||||
film: '<rect x="2" y="2" width="20" height="20" rx="2.2"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line>',
|
||||
headphones: '<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>',
|
||||
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>',
|
||||
git: '<circle cx="12" cy="12" r="4"></circle><line x1="1.05" y1="12" x2="7" y2="12"></line><line x1="17.01" y1="12" x2="22.96" y2="12"></line><path d="M12 1.05V7"></path><path d="M12 17.01v5.95"></path>',
|
||||
chess: '<path d="M8 16l-1 4h10l-1-4"></path><path d="M6.5 16h11"></path><path d="M12 2a2 2 0 0 1 2 2c0 1.1-.9 2-2 3-1.1-1-2-1.9-2-3a2 2 0 0 1 2-2z"></path><path d="M9 7 6 10.5c-.6.7-.2 1.5.7 1.5h10.6c.9 0 1.3-.8.7-1.5L15 7"></path><path d="M9 12v4"></path><path d="M15 12v4"></path>',
|
||||
user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle>'
|
||||
};
|
||||
|
||||
let cards = [];
|
||||
let sections = [];
|
||||
let config = null;
|
||||
let favorites = [];
|
||||
let activeFilter = 'all';
|
||||
let activeView = window.localStorage.getItem('mischlabs.dashboard.view') || 'all';
|
||||
let categoryById = new Map();
|
||||
|
||||
const searchInput = document.querySelector('#serviceSearch');
|
||||
const segments = [...document.querySelectorAll('.segment')];
|
||||
const viewOptions = [...document.querySelectorAll('.view-option')];
|
||||
const emptyState = document.querySelector('#emptyState');
|
||||
const favoriteCount = document.querySelector('#favoriteCount');
|
||||
const serviceCount = document.querySelector('#serviceCount');
|
||||
const lastChecked = document.querySelector('#lastChecked');
|
||||
const dashboardSections = document.querySelector('#dashboardSections');
|
||||
const favoritesSections = document.querySelector('#favoritesSections');
|
||||
const loginButton = document.querySelector('#dashboardLoginButton');
|
||||
const logoutButton = document.querySelector('#dashboardLogoutButton');
|
||||
const favoritesLink = document.querySelector('#favoritesLink');
|
||||
const userBadge = document.querySelector('#dashboardUserBadge');
|
||||
const dashboardMenuButton = document.querySelector('#dashboardMenuButton');
|
||||
const dashboardMenuPanel = document.querySelector('#dashboardMenuPanel');
|
||||
let adminPanelLink = null;
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getLocalConfig() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LOCAL_CONFIG_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const response = await fetch(CONFIG_URL, { cache: 'no-store' });
|
||||
const baseConfig = await response.json();
|
||||
return getLocalConfig() || baseConfig;
|
||||
}
|
||||
|
||||
function serviceSearchText(service) {
|
||||
return [
|
||||
service.name,
|
||||
service.description,
|
||||
service.domain,
|
||||
service.category,
|
||||
service.keywords
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function isMobileDevice() {
|
||||
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function getMobilePlatform() {
|
||||
const userAgent = navigator.userAgent || '';
|
||||
if (/Android/i.test(userAgent)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(userAgent)) return 'ios';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function getMobileLaunch(service) {
|
||||
if (service.id === 'drive') {
|
||||
const fallbackUrl = service.url;
|
||||
if (getMobilePlatform() === 'android') {
|
||||
return {
|
||||
url: `intent://openFiles#Intent;scheme=nextcloud;package=com.nextcloud.client;S.browser_fallback_url=${encodeURIComponent(fallbackUrl)};end`,
|
||||
fallbackUrl
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: 'nextcloud://openFiles',
|
||||
fallbackUrl
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openMobileLaunch(url, fallbackUrl) {
|
||||
let didLeavePage = false;
|
||||
let fallbackTimer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pagehide', markPageLeft);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (fallbackTimer) window.clearTimeout(fallbackTimer);
|
||||
};
|
||||
|
||||
const markPageLeft = () => {
|
||||
didLeavePage = true;
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
markPageLeft();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', markPageLeft, { once: true });
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
fallbackTimer = window.setTimeout(() => {
|
||||
cleanup();
|
||||
if (!didLeavePage && document.visibilityState === 'visible') {
|
||||
window.location.href = fallbackUrl;
|
||||
}
|
||||
}, 2200);
|
||||
|
||||
if (/^https?:\/\//i.test(url) || /^intent:/i.test(url)) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function createServiceCard(service) {
|
||||
const category = categoryById.get(service.category);
|
||||
const card = document.createElement('a');
|
||||
card.href = service.url;
|
||||
card.target = '_blank';
|
||||
card.rel = 'noopener';
|
||||
card.className = 'card service-card';
|
||||
card.dataset.category = service.category;
|
||||
card.dataset.url = service.url;
|
||||
card.dataset.name = serviceSearchText(service);
|
||||
card.innerHTML = `
|
||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
||||
<div class="card-icon" style="--accent: ${service.accent};">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
${icons[service.icon] || icons.folder}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="card-category">${category?.label || 'Dienst'}</span>
|
||||
<h3>${service.name}</h3>
|
||||
<p>${service.description}</p>
|
||||
</div>
|
||||
<span class="card-domain">${service.domain}</span>
|
||||
`;
|
||||
|
||||
const mobileLaunch = getMobileLaunch(service);
|
||||
if (mobileLaunch) {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (!isMobileDevice()) return;
|
||||
|
||||
e.preventDefault();
|
||||
openMobileLaunch(mobileLaunch.url, mobileLaunch.fallbackUrl);
|
||||
});
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function createFavoriteCard(favorite) {
|
||||
const card = document.createElement('a');
|
||||
card.href = favorite.url;
|
||||
card.target = '_blank';
|
||||
card.rel = 'noopener';
|
||||
card.className = 'card favorite-card';
|
||||
card.style.setProperty('--accent', favorite.color || '#67e8f9');
|
||||
card.dataset.name = [
|
||||
favorite.name,
|
||||
favorite.description,
|
||||
favorite.group,
|
||||
favorite.url
|
||||
].join(' ');
|
||||
|
||||
const safeLogo = favorite.logoUrl ? escapeHtml(favorite.logoUrl) : '';
|
||||
const safeName = escapeHtml(favorite.name);
|
||||
const safeDescription = escapeHtml(favorite.description || favorite.group || 'Favorit');
|
||||
const iconSvg = icons[favorite.icon] || icons.folder;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-icon favorite-icon" style="--accent: ${favorite.color || '#67e8f9'};">
|
||||
${safeLogo
|
||||
? `<img src="${safeLogo}" alt="" loading="lazy">`
|
||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${iconSvg}</svg>`}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="card-category">${escapeHtml(favorite.group || 'Favorit')}</span>
|
||||
<h3>${safeName}</h3>
|
||||
<p>${safeDescription}</p>
|
||||
</div>
|
||||
<span class="card-domain">${escapeHtml(new URL(favorite.url).hostname)}</span>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderFavorites() {
|
||||
if (favoriteCount) {
|
||||
favoriteCount.textContent = String(favorites.length);
|
||||
}
|
||||
|
||||
if (!favoritesSections) return;
|
||||
|
||||
if (!MischLabsAuth.isAuthenticated()) {
|
||||
favoritesSections.innerHTML = `
|
||||
<section class="section favorites-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Persoenlich</p>
|
||||
<h2>Deine Favoriten</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorites-empty">
|
||||
<p>Melde dich mit SSO an, um eigene Kacheln und Weblinks zu speichern.</p>
|
||||
<button class="ghost-button mini" id="favoritesLoginCallout" type="button">Mit SSO anmelden</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
document.querySelector('#favoritesLoginCallout')?.addEventListener('click', () => MischLabsAuth.login());
|
||||
updateVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!favorites.length) {
|
||||
favoritesSections.innerHTML = `
|
||||
<section class="section favorites-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Persoenlich</p>
|
||||
<h2>Deine Favoriten</h2>
|
||||
</div>
|
||||
<a class="ghost-button mini" href="/favorites.html">Favoriten erstellen</a>
|
||||
</div>
|
||||
<div class="favorites-empty">
|
||||
<p>Noch keine Favoriten. Lege eigene Kacheln fuer YouTube, Notion, Tools oder beliebige Links an.</p>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
updateVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = new Map();
|
||||
favorites.forEach((favorite) => {
|
||||
const group = favorite.group || 'Favoriten';
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group).push(favorite);
|
||||
});
|
||||
|
||||
favoritesSections.innerHTML = `
|
||||
<section class="section favorites-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Persoenlich</p>
|
||||
<h2>Deine Favoriten</h2>
|
||||
</div>
|
||||
<a class="ghost-button mini" href="/favorites.html">Bearbeiten</a>
|
||||
</div>
|
||||
<div class="favorite-groups"></div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const container = favoritesSections.querySelector('.favorite-groups');
|
||||
[...groups.entries()].forEach(([group, items]) => {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'favorite-group';
|
||||
section.innerHTML = `
|
||||
<div class="favorite-group-heading">
|
||||
<h3>${escapeHtml(group)}</h3>
|
||||
<span>${items.length} ${items.length === 1 ? 'Link' : 'Links'}</span>
|
||||
</div>
|
||||
<div class="grid favorite-grid"></div>
|
||||
`;
|
||||
const grid = section.querySelector('.grid');
|
||||
items.forEach((favorite) => grid.appendChild(createFavoriteCard(favorite)));
|
||||
container.appendChild(section);
|
||||
});
|
||||
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
if (!MischLabsAuth.isAuthenticated()) {
|
||||
favorites = [];
|
||||
renderFavorites();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/favorites', {
|
||||
cache: 'no-store',
|
||||
headers: MischLabsAuth.authHeader()
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
|
||||
} catch {
|
||||
favorites = [];
|
||||
}
|
||||
|
||||
renderFavorites();
|
||||
}
|
||||
|
||||
function renderDashboard(nextConfig) {
|
||||
dashboardSections.innerHTML = '';
|
||||
serviceCount.textContent = String(nextConfig.services.length);
|
||||
categoryById = new Map(nextConfig.categories.map((category) => [category.id, category]));
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.className = 'section service-overview';
|
||||
section.innerHTML = `
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker" id="overviewKicker">Dashboard</p>
|
||||
<h2 id="overviewTitle">Alle Dienste</h2>
|
||||
</div>
|
||||
<span class="section-count" id="overviewCount">${nextConfig.services.length} Dienste</span>
|
||||
</div>
|
||||
<div class="grid"></div>
|
||||
`;
|
||||
|
||||
const grid = section.querySelector('.grid');
|
||||
nextConfig.services.forEach((service) => grid.appendChild(createServiceCard(service)));
|
||||
dashboardSections.appendChild(section);
|
||||
|
||||
cards = [...document.querySelectorAll('.service-card')];
|
||||
sections = [...document.querySelectorAll('.section')];
|
||||
updateOverviewHeading(nextConfig.services.length);
|
||||
}
|
||||
|
||||
function updateOverviewHeading(visibleCards) {
|
||||
const activeCategory = categoryById.get(activeFilter);
|
||||
const term = normalize(searchInput.value);
|
||||
const kicker = document.querySelector('#overviewKicker');
|
||||
const title = document.querySelector('#overviewTitle');
|
||||
const count = document.querySelector('#overviewCount');
|
||||
if (!kicker || !title || !count) return;
|
||||
|
||||
kicker.textContent = activeCategory ? activeCategory.label : 'Dashboard';
|
||||
title.textContent = activeCategory ? activeCategory.title : 'Alle Dienste';
|
||||
count.textContent = `${visibleCards} ${visibleCards === 1 ? 'Dienst' : 'Dienste'}${term ? ' gefunden' : ''}`;
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
const term = normalize(searchInput.value);
|
||||
let visibleCards = 0;
|
||||
let visibleFavorites = 0;
|
||||
const showServices = activeView === 'all' || activeView === 'services';
|
||||
const showFavorites = activeView === 'all' || activeView === 'favorites';
|
||||
|
||||
if (dashboardSections) {
|
||||
dashboardSections.hidden = !showServices;
|
||||
}
|
||||
|
||||
const categoryFilters = document.querySelector('#categoryFilters');
|
||||
if (categoryFilters) {
|
||||
categoryFilters.hidden = !showServices;
|
||||
}
|
||||
|
||||
if (favoritesSections) {
|
||||
favoritesSections.hidden = !showFavorites;
|
||||
}
|
||||
|
||||
cards.forEach((card) => {
|
||||
const categoryMatches = activeFilter === 'all' || card.dataset.category === activeFilter;
|
||||
const searchMatches = !term || normalize(card.dataset.name).includes(term);
|
||||
const isVisible = showServices && categoryMatches && searchMatches;
|
||||
card.hidden = !isVisible;
|
||||
if (isVisible) visibleCards += 1;
|
||||
});
|
||||
|
||||
sections.forEach((section) => {
|
||||
const hasVisibleCards = [...section.querySelectorAll('.service-card')].some((card) => !card.hidden);
|
||||
section.hidden = !showServices || !hasVisibleCards;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.favorite-card').forEach((card) => {
|
||||
const isVisible = showFavorites && (!term || normalize(card.dataset.name).includes(term));
|
||||
card.hidden = !isVisible;
|
||||
if (isVisible) visibleFavorites += 1;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.favorite-group').forEach((group) => {
|
||||
const hasVisibleCards = [...group.querySelectorAll('.favorite-card')].some((card) => !card.hidden);
|
||||
group.hidden = !showFavorites || !hasVisibleCards;
|
||||
});
|
||||
|
||||
updateOverviewHeading(visibleCards);
|
||||
const hasVisibleContent = visibleCards + visibleFavorites > 0;
|
||||
emptyState.hidden = hasVisibleContent;
|
||||
emptyState.textContent = term
|
||||
? 'Kein Eintrag passt zur Suche.'
|
||||
: 'In dieser Ansicht gibt es noch keine Eintraege.';
|
||||
}
|
||||
|
||||
function setActiveFilter(nextFilter) {
|
||||
activeFilter = nextFilter;
|
||||
segments.forEach((segment) => {
|
||||
const isActive = segment.dataset.filter === nextFilter;
|
||||
segment.classList.toggle('is-active', isActive);
|
||||
segment.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
function setActiveView(nextView) {
|
||||
activeView = nextView;
|
||||
window.localStorage.setItem('mischlabs.dashboard.view', nextView);
|
||||
viewOptions.forEach((option) => {
|
||||
const isActive = option.dataset.view === nextView;
|
||||
option.classList.toggle('is-active', isActive);
|
||||
option.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
async function probeService(card) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 4500);
|
||||
|
||||
try {
|
||||
await fetch(card.dataset.url, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
card.classList.add('is-online');
|
||||
card.classList.remove('is-offline');
|
||||
card.classList.remove('is-unknown');
|
||||
card.querySelector('.status-dot').setAttribute('aria-label', 'Online');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const isTimeout = error.name === 'AbortError';
|
||||
card.classList.toggle('is-offline', isTimeout);
|
||||
card.classList.toggle('is-unknown', !isTimeout);
|
||||
card.classList.remove('is-online');
|
||||
card.querySelector('.status-dot').setAttribute(
|
||||
'aria-label',
|
||||
isTimeout ? 'Nicht erreichbar' : 'Im Browser nicht pruefbar'
|
||||
);
|
||||
return isTimeout ? false : null;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const results = await Promise.allSettled(cards.map(probeService));
|
||||
const unknown = results.filter((result) => result.status === 'fulfilled' && result.value === null).length;
|
||||
lastChecked.textContent = `Status geprueft: ${new Date().toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}${unknown ? `, ${unknown} im Browser nicht pruefbar` : ''}`;
|
||||
}
|
||||
|
||||
function scheduleStatusRefresh() {
|
||||
const run = () => refreshStatus().catch(() => {
|
||||
lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
|
||||
});
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(run, { timeout: 2000 });
|
||||
} else {
|
||||
window.setTimeout(run, 600);
|
||||
}
|
||||
}
|
||||
|
||||
segments.forEach((segment) => {
|
||||
segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter));
|
||||
});
|
||||
|
||||
viewOptions.forEach((option) => {
|
||||
option.addEventListener('click', () => setActiveView(option.dataset.view));
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', updateVisibility);
|
||||
|
||||
function closeDashboardMenu() {
|
||||
if (!dashboardMenuButton || !dashboardMenuPanel) return;
|
||||
dashboardMenuButton.setAttribute('aria-expanded', 'false');
|
||||
dashboardMenuButton.classList.remove('is-open');
|
||||
dashboardMenuPanel.classList.remove('is-open');
|
||||
dashboardMenuPanel.hidden = true;
|
||||
}
|
||||
|
||||
function toggleDashboardMenu() {
|
||||
if (!dashboardMenuButton || !dashboardMenuPanel) return;
|
||||
const isOpen = dashboardMenuButton.getAttribute('aria-expanded') === 'true';
|
||||
dashboardMenuButton.setAttribute('aria-expanded', String(!isOpen));
|
||||
dashboardMenuButton.classList.toggle('is-open', !isOpen);
|
||||
dashboardMenuPanel.classList.toggle('is-open', !isOpen);
|
||||
dashboardMenuPanel.hidden = isOpen;
|
||||
}
|
||||
|
||||
function hasAdminAccess(claims) {
|
||||
const username = String(claims?.preferred_username || '').toLowerCase();
|
||||
const email = String(claims?.email || '').toLowerCase();
|
||||
return username === 'mrdiderot' || email === 'mail.misch@pm.me';
|
||||
}
|
||||
|
||||
function ensureAdminPanelLink() {
|
||||
if (adminPanelLink || !dashboardMenuPanel) return adminPanelLink;
|
||||
|
||||
adminPanelLink = document.createElement('a');
|
||||
adminPanelLink.className = 'dashboard-menu-item';
|
||||
adminPanelLink.id = 'adminPanelLink';
|
||||
adminPanelLink.href = '/admin.html';
|
||||
adminPanelLink.textContent = 'Admin Panel';
|
||||
adminPanelLink.hidden = true;
|
||||
|
||||
dashboardMenuPanel.insertBefore(adminPanelLink, favoritesLink || loginButton || null);
|
||||
return adminPanelLink;
|
||||
}
|
||||
|
||||
function updateAuthNav() {
|
||||
const authenticated = MischLabsAuth.isAuthenticated();
|
||||
const claims = MischLabsAuth.claims();
|
||||
const isAdmin = authenticated && hasAdminAccess(claims);
|
||||
if (loginButton) loginButton.hidden = authenticated;
|
||||
if (logoutButton) logoutButton.hidden = !authenticated;
|
||||
if (favoritesLink) favoritesLink.hidden = !authenticated;
|
||||
if (isAdmin) {
|
||||
const link = ensureAdminPanelLink();
|
||||
if (link) link.hidden = false;
|
||||
} else if (adminPanelLink) {
|
||||
adminPanelLink.remove();
|
||||
adminPanelLink = null;
|
||||
}
|
||||
if (userBadge) {
|
||||
userBadge.hidden = !authenticated;
|
||||
userBadge.textContent = authenticated ? MischLabsAuth.displayName() : '';
|
||||
}
|
||||
}
|
||||
|
||||
dashboardMenuButton?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleDashboardMenu();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!dashboardMenuPanel || dashboardMenuPanel.hidden) return;
|
||||
if (!event.target.closest('.dashboard-menu')) {
|
||||
closeDashboardMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDashboardMenu();
|
||||
}
|
||||
});
|
||||
|
||||
loginButton?.addEventListener('click', () => MischLabsAuth.login());
|
||||
logoutButton?.addEventListener('click', () => MischLabsAuth.logout());
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
MischLabsAuth.handleCallback()
|
||||
.catch((error) => {
|
||||
lastChecked.textContent = error.message;
|
||||
})
|
||||
.then(() => loadConfig())
|
||||
.then((nextConfig) => {
|
||||
config = nextConfig;
|
||||
updateAuthNav();
|
||||
renderDashboard(config);
|
||||
setActiveView(activeView);
|
||||
updateVisibility();
|
||||
loadFavorites();
|
||||
scheduleStatusRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
dashboardSections.innerHTML = '<p class="empty-state">Dashboard-Konfiguration konnte nicht geladen werden.</p>';
|
||||
if (favoriteCount) {
|
||||
favoriteCount.textContent = '--';
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Peeking Bongo Cat Easter Egg Logic
|
||||
// ==========================================================================
|
||||
(function initPeekingCat() {
|
||||
const peekingCat = document.querySelector('#peekingCat');
|
||||
const searchInput = document.querySelector('#serviceSearch');
|
||||
|
||||
if (!peekingCat) return;
|
||||
|
||||
let tapTimeout = null;
|
||||
|
||||
// Function to trigger heart popup on click
|
||||
function createHeart(x, y) {
|
||||
const heart = document.createElement('div');
|
||||
heart.className = 'cat-heart';
|
||||
heart.innerHTML = '❤️';
|
||||
heart.style.left = `${x - 12}px`;
|
||||
heart.style.top = `${y - 20}px`;
|
||||
|
||||
// Set random rotation angle in CSS custom variable
|
||||
const randomRot = Math.random() * 40 - 20;
|
||||
heart.style.setProperty('--rot', `${randomRot}deg`);
|
||||
|
||||
document.body.appendChild(heart);
|
||||
|
||||
// Cleanup heart after animation completes
|
||||
setTimeout(() => heart.remove(), 800);
|
||||
}
|
||||
|
||||
// Focus search box triggers peeking active state
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('focus', () => {
|
||||
peekingCat.classList.add('is-active');
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', () => {
|
||||
peekingCat.classList.remove('is-active');
|
||||
peekingCat.classList.remove('is-tapping');
|
||||
});
|
||||
|
||||
// Keyboard typing triggers tapping animation!
|
||||
searchInput.addEventListener('input', () => {
|
||||
peekingCat.classList.add('is-active');
|
||||
peekingCat.classList.add('is-tapping');
|
||||
|
||||
if (tapTimeout) clearTimeout(tapTimeout);
|
||||
tapTimeout = setTimeout(() => {
|
||||
peekingCat.classList.remove('is-tapping');
|
||||
}, 350); // Stop tapping 350ms after typing stops
|
||||
});
|
||||
}
|
||||
|
||||
// Click on cat triggers tapping + heart animation!
|
||||
peekingCat.addEventListener('click', (e) => {
|
||||
peekingCat.classList.add('is-tapping');
|
||||
createHeart(e.clientX, e.clientY);
|
||||
|
||||
if (tapTimeout) clearTimeout(tapTimeout);
|
||||
tapTimeout = setTimeout(() => {
|
||||
peekingCat.classList.remove('is-tapping');
|
||||
}, 800);
|
||||
});
|
||||
})();
|
||||
201
auth.js
Normal file
@@ -0,0 +1,201 @@
|
||||
const MischLabsAuth = (() => {
|
||||
const TOKEN_KEY = 'mischlabs.dashboard.tokens';
|
||||
const PKCE_KEY = 'mischlabs.dashboard.pkce';
|
||||
const CONFIG_KEY = 'mischlabs.dashboard.authConfig';
|
||||
const SCOPES = 'openid profile email';
|
||||
|
||||
let configPromise = null;
|
||||
let cachedConfig = null;
|
||||
|
||||
function base64UrlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function randomString(length = 64) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return [...bytes].map((byte) => chars[byte % chars.length]).join('');
|
||||
}
|
||||
|
||||
async function createChallenge(verifier) {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
if (!configPromise) {
|
||||
configPromise = fetch('/api/auth/config', { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Auth config returned ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then((config) => {
|
||||
cachedConfig = config;
|
||||
sessionStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
return config;
|
||||
})
|
||||
.catch((error) => {
|
||||
const fallback = JSON.parse(sessionStorage.getItem(CONFIG_KEY) || 'null');
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
function redirectUri() {
|
||||
const path = window.location.pathname === '/index.html' ? '/' : window.location.pathname;
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = decodeURIComponent(atob(payload).split('').map((char) => (
|
||||
`%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`
|
||||
)).join(''));
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeTokens(nextTokens) {
|
||||
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(nextTokens));
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(PKCE_KEY);
|
||||
}
|
||||
|
||||
function isAuthenticated() {
|
||||
const current = tokens();
|
||||
if (!current?.id_token) return false;
|
||||
try {
|
||||
return Date.now() < decodeJwt(current.id_token).exp * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function claims() {
|
||||
const current = tokens();
|
||||
if (!current?.id_token) return null;
|
||||
try {
|
||||
return decodeJwt(current.id_token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
const current = tokens();
|
||||
return current?.id_token ? { Authorization: `Bearer ${current.id_token}` } : {};
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const config = await getConfig();
|
||||
const verifier = randomString();
|
||||
const challenge = await createChallenge(verifier);
|
||||
const state = randomString(32);
|
||||
const redirect = redirectUri();
|
||||
sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, redirect }));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirect,
|
||||
response_type: 'code',
|
||||
scope: config.scopes || SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
window.location.href = `${config.authority}/protocol/openid-connect/auth?${params}`;
|
||||
}
|
||||
|
||||
async function handleCallback() {
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
window.history.replaceState({}, document.title, redirectUri());
|
||||
throw new Error(`SSO Fehler: ${error}`);
|
||||
}
|
||||
|
||||
if (!code) return false;
|
||||
|
||||
const config = await getConfig();
|
||||
const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
|
||||
if (!stored.verifier || stored.state !== state) {
|
||||
throw new Error('SSO Antwort konnte nicht verifiziert werden.');
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: stored.redirect || redirectUri(),
|
||||
code,
|
||||
code_verifier: stored.verifier
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.authority}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token endpoint returned ${response.status}`);
|
||||
}
|
||||
|
||||
storeTokens(await response.json());
|
||||
sessionStorage.removeItem(PKCE_KEY);
|
||||
window.history.replaceState({}, document.title, redirectUri());
|
||||
return true;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const config = await getConfig();
|
||||
const idToken = tokens()?.id_token;
|
||||
clearTokens();
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
post_logout_redirect_uri: redirectUri()
|
||||
});
|
||||
if (idToken) params.set('id_token_hint', idToken);
|
||||
window.location.href = `${config.authority}/protocol/openid-connect/logout?${params}`;
|
||||
}
|
||||
|
||||
function displayName() {
|
||||
const currentClaims = claims();
|
||||
return currentClaims?.name || currentClaims?.preferred_username || currentClaims?.email || 'Angemeldet';
|
||||
}
|
||||
|
||||
return {
|
||||
authHeader,
|
||||
claims,
|
||||
clearTokens,
|
||||
displayName,
|
||||
getConfig,
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
tokens
|
||||
};
|
||||
})();
|
||||
74
brain.old
Normal file
@@ -0,0 +1,74 @@
|
||||
# Brain.md - Projekt mischlabs.de Landing Page
|
||||
|
||||
## Projektziel
|
||||
Eine ansprechende Landing Page fuer mischlabs.de, die alle Dienste als Kacheln mit direkten Verlinkungen anzeigt. Laeuft als Docker-Container und wird ueber Git auto-deployed.
|
||||
|
||||
## Status
|
||||
- [x] Git-Repo initialisiert und Remote verbunden (https://git.mischlabs.de/MrDiderot/mischlabs.git)
|
||||
- [x] Dienste-Liste vom Screenshot erfasst (9 Services inkl. MiChess)
|
||||
- [x] Landing Page Design und Implementierung (index.html + style.css)
|
||||
- [x] Docker-Setup (Dockerfile, docker-compose.yml, nginx.conf)
|
||||
- [x] Push zum Remote
|
||||
- [x] MiChess-Kachel hinzugefuegt (Commit lokal, Push steht aus wg. Auth-Fehler)
|
||||
- [ ] Push des MiChess-Commits (Git-Auth-Token erneuern)
|
||||
|
||||
## Verwandte Projekte
|
||||
- **auth** (d:\Vibecoding\Website\auth) - Keycloak SSO fuer alle Dienste
|
||||
- Repo: https://git.mischlabs.de/MrDiderot/auth.git
|
||||
- Subdomain: auth.mischlabs.de
|
||||
- Siehe auth/Brain.md fuer Details
|
||||
|
||||
## Dienste-Uebersicht (8 Stueck)
|
||||
| # | Subdomain | Beschreibung | Farbe/Accent |
|
||||
|---|-----------|-------------|--------------|
|
||||
| 1 | drive.mischlabs.de | Cloud-Speicher | #4285f4 (Blau) |
|
||||
| 2 | git.mischlabs.de | Code-Repositories | #f05033 (Rot) |
|
||||
| 3 | movies.mischlabs.de | Filme & Serien | #a855f7 (Lila) |
|
||||
| 4 | audiobook.mischlabs.de | Hoerbuecher | #22c55e (Gruen) |
|
||||
| 5 | password.mischlabs.de | Passwort-Manager | #eab308 (Gelb) |
|
||||
| 6 | docs.mischlabs.de | Dokumentation | #06b6d4 (Cyan) |
|
||||
| 7 | michess.mischlabs.de | Schach | #10b981 (Gruen) |
|
||||
| 8 | tom.mischlabs.de | Persoenliche Seite | #ec4899 (Pink) |
|
||||
|
||||
## Technische Entscheidungen
|
||||
- **Reines HTML + CSS** (kein Framework, kein JS) - maximale Performance, minimales Docker-Image
|
||||
- **nginx:alpine** als Base-Image - extrem leichtgewichtig (~5MB)
|
||||
- **Google Fonts (Inter)** fuer moderne Typografie
|
||||
- **Inline SVG Icons** - keine externen Dependencies
|
||||
- **CSS Grid** mit `auto-fill` + `minmax(240px, 1fr)` fuer responsives Layout
|
||||
- **Dark Theme** mit subtilen Glow-Effekten
|
||||
- **Staggered Animations**: Kacheln faden nacheinander ein (9 Stueck)
|
||||
|
||||
## Docker-Setup
|
||||
- **Container-Name**: `mischlabs`
|
||||
- **Base-Image**: `nginx:alpine`
|
||||
- **Port-Mapping**: `8085:80` (anpassbar in docker-compose.yml)
|
||||
- **Restart-Policy**: `unless-stopped`
|
||||
- **nginx.conf**: Caching fuer Static Assets (7 Tage), Security-Headers
|
||||
|
||||
## Dateistruktur
|
||||
```
|
||||
mischlabs/
|
||||
index.html - Haupt-HTML mit allen 9 Service-Kacheln
|
||||
style.css - Komplettes Styling (Dark Theme, Grid, Animationen)
|
||||
Dockerfile - nginx:alpine mit kopierten Static Files
|
||||
docker-compose.yml - Container-Definition (Name: mischlabs, Port 8085)
|
||||
nginx.conf - Server-Konfiguration, Caching, Security-Headers
|
||||
.gitignore - Git-Ausschluesse
|
||||
Brain.md - Dieses Dokument (KI-Uebergabe)
|
||||
```
|
||||
|
||||
## Deployment-Workflow
|
||||
1. Code aendern und committen
|
||||
2. Push zu git.mischlabs.de
|
||||
3. Auf dem Server: `git pull && docker compose up -d --build`
|
||||
|
||||
## Erkenntnisse & Notizen
|
||||
- Alle Dienste laufen auf 192.168.178.88 mit verschiedenen Ports
|
||||
- Oeffentlich erreichbar ueber *.mischlabs.de Subdomains (Cloudflare Tunnel)
|
||||
- Port 8085 gewaehlt da gaengige Ports (80, 8080, 8090) bereits belegt
|
||||
- Kein JavaScript noetig - pure CSS reicht fuer alle Effekte
|
||||
- `color-mix()` CSS-Funktion fuer dynamische Icon-Hintergruende
|
||||
- Git-Push-Fehler aufgetreten (Authentication failed) - Token muss erneuert werden
|
||||
- DNS: Alte A-Records (185.181.104.242) von INWX wurden geloescht, nur noch Tunnel-Eintraege
|
||||
- NAS hat kein git installiert - entweder Git Server Paket installieren oder wget nutzen
|
||||
@@ -1,7 +1,15 @@
|
||||
services:
|
||||
mischlabs:
|
||||
build: .
|
||||
image: git.mischlabs.de/mrdiderot/mischlabs:latest
|
||||
container_name: mischlabs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8085:80"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /volume1:/host/volume1:ro
|
||||
- /volume2:/host/volume2:ro
|
||||
- mischlabs_data:/app/data
|
||||
|
||||
volumes:
|
||||
mischlabs_data:
|
||||
|
||||
BIN
favicon.ico
Normal file
|
After Width: | Height: | Size: 768 B |
73
favorites.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MischLabs - Favoriten</title>
|
||||
<meta name="description" content="Persoenliche MischLabs Favoriten verwalten.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=4">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=4">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
|
||||
<link rel="stylesheet" href="/style.css?v=34">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero admin-hero">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
|
||||
<div>
|
||||
<p class="eyebrow">Persoenlich</p>
|
||||
<h1>Favoriten</h1>
|
||||
<p class="subtitle">Eigene Weblinks als Kacheln auf deinem MischLabs Dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions">
|
||||
<a class="ghost-button" href="/">Dashboard</a>
|
||||
<button class="ghost-button" id="favoritesLoginButton" type="button">Mit SSO anmelden</button>
|
||||
<button class="ghost-button" id="favoritesLogoutButton" type="button" hidden>Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="shell favorites-shell">
|
||||
<section class="admin-panel" id="favoritesLoginPanel">
|
||||
<p class="section-kicker">SSO</p>
|
||||
<h2>Anmeldung erforderlich</h2>
|
||||
<p class="admin-copy">Deine Favoriten werden pro Keycloak-Benutzer gespeichert.</p>
|
||||
<div class="notice" id="favoritesMessage">Noch nicht angemeldet.</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" id="favoritesPanel" hidden>
|
||||
<div class="admin-panel-head">
|
||||
<div>
|
||||
<p class="section-kicker">Editor</p>
|
||||
<h2>Favoriten verwalten</h2>
|
||||
<p class="admin-copy">Lege Kacheln an, waehle Farbe und Logo, gruppiere sie und sortiere die Reihenfolge.</p>
|
||||
</div>
|
||||
<button class="ghost-button" id="addFavoriteButton" type="button">Favorit hinzufuegen</button>
|
||||
</div>
|
||||
|
||||
<div class="notice" id="favoritesUser"></div>
|
||||
|
||||
<div class="favorite-editor-layout">
|
||||
<div class="favorite-editor-list" id="favoriteEditor"></div>
|
||||
<aside class="favorite-preview-panel">
|
||||
<p class="section-kicker">Vorschau</p>
|
||||
<div id="favoritePreview" class="grid favorite-preview-grid"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions-bar">
|
||||
<button class="ghost-button" id="saveFavoritesButton" type="button">Speichern</button>
|
||||
<button class="ghost-button" id="reloadFavoritesButton" type="button">Neu laden</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/auth.js?v=2" defer></script>
|
||||
<script src="/favorites.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
239
favorites.js
Normal file
@@ -0,0 +1,239 @@
|
||||
const favoriteIcons = {
|
||||
star: '<polygon points="12 2 15 8.5 22 9.3 16.8 14 18.2 21 12 17.4 5.8 21 7.2 14 2 9.3 9 8.5 12 2"></polygon>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.1 0l2.8-2.8a5 5 0 0 0-7.1-7.1l-1.6 1.6"></path><path d="M14 11a5 5 0 0 0-7.1 0l-2.8 2.8a5 5 0 0 0 7.1 7.1l1.6-1.6"></path>',
|
||||
play: '<circle cx="12" cy="12" r="10"></circle><path d="m10 8 6 4-6 4z"></path>',
|
||||
note: '<path d="M4 3h11l5 5v13H4z"></path><path d="M14 3v6h6"></path><path d="M8 13h8"></path><path d="M8 17h6"></path>',
|
||||
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>',
|
||||
code: '<path d="m16 18 6-6-6-6"></path><path d="m8 6-6 6 6 6"></path>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m3 7 9 6 9-6"></path>',
|
||||
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"></rect><path d="M16 2v4"></path><path d="M8 2v4"></path><path d="M3 10h18"></path>',
|
||||
image: '<rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="m21 15-5-5L5 21"></path>',
|
||||
tool: '<path d="M14.7 6.3a4 4 0 0 0-5 5L3 18v3h3l6.7-6.7a4 4 0 0 0 5-5l-2.8 2.8-2-2z"></path>'
|
||||
};
|
||||
|
||||
const iconLabels = {
|
||||
star: 'Stern',
|
||||
link: 'Link',
|
||||
play: 'Video',
|
||||
note: 'Notiz',
|
||||
book: 'Buch',
|
||||
code: 'Code',
|
||||
mail: 'Mail',
|
||||
calendar: 'Kalender',
|
||||
image: 'Bild',
|
||||
tool: 'Tool'
|
||||
};
|
||||
|
||||
const loginButton = document.querySelector('#favoritesLoginButton');
|
||||
const logoutButton = document.querySelector('#favoritesLogoutButton');
|
||||
const loginPanel = document.querySelector('#favoritesLoginPanel');
|
||||
const panel = document.querySelector('#favoritesPanel');
|
||||
const message = document.querySelector('#favoritesMessage');
|
||||
const userBox = document.querySelector('#favoritesUser');
|
||||
const editor = document.querySelector('#favoriteEditor');
|
||||
const preview = document.querySelector('#favoritePreview');
|
||||
const addButton = document.querySelector('#addFavoriteButton');
|
||||
const saveButton = document.querySelector('#saveFavoritesButton');
|
||||
const reloadButton = document.querySelector('#reloadFavoritesButton');
|
||||
|
||||
let favorites = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function createDefaultFavorite() {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Neuer Link',
|
||||
url: 'https://',
|
||||
group: 'Favoriten',
|
||||
description: '',
|
||||
color: '#67e8f9',
|
||||
icon: 'star',
|
||||
logoUrl: '',
|
||||
order: favorites.length
|
||||
};
|
||||
}
|
||||
|
||||
function iconOptions(selected) {
|
||||
return Object.keys(favoriteIcons).map((icon) => (
|
||||
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${iconLabels[icon]}</option>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function syncFromInputs() {
|
||||
editor.querySelectorAll('.favorite-edit-card').forEach((row) => {
|
||||
const favorite = favorites[Number(row.dataset.index)];
|
||||
if (!favorite) return;
|
||||
row.querySelectorAll('[data-field]').forEach((input) => {
|
||||
favorite[input.dataset.field] = input.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function moveFavorite(index, direction) {
|
||||
const next = index + direction;
|
||||
if (next < 0 || next >= favorites.length) return;
|
||||
syncFromInputs();
|
||||
[favorites[index], favorites[next]] = [favorites[next], favorites[index]];
|
||||
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
|
||||
render();
|
||||
}
|
||||
|
||||
function removeFavorite(index) {
|
||||
syncFromInputs();
|
||||
favorites.splice(index, 1);
|
||||
favorites = favorites.map((favorite, order) => ({ ...favorite, order }));
|
||||
render();
|
||||
}
|
||||
|
||||
function renderPreviewCard(favorite) {
|
||||
const color = /^#[0-9a-fA-F]{6}$/.test(favorite.color) ? favorite.color : '#67e8f9';
|
||||
const iconSvg = favoriteIcons[favorite.icon] || favoriteIcons.star;
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(favorite.url).hostname;
|
||||
} catch {
|
||||
host = 'ungueltige-url';
|
||||
}
|
||||
|
||||
return `
|
||||
<article class="card favorite-card" style="--accent: ${color};">
|
||||
<div class="card-icon favorite-icon" style="--accent: ${color};">
|
||||
${favorite.logoUrl
|
||||
? `<img src="${escapeHtml(favorite.logoUrl)}" alt="" loading="lazy">`
|
||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${iconSvg}</svg>`}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="card-category">${escapeHtml(favorite.group || 'Favorit')}</span>
|
||||
<h3>${escapeHtml(favorite.name)}</h3>
|
||||
<p>${escapeHtml(favorite.description || 'Eigener Weblink')}</p>
|
||||
</div>
|
||||
<span class="card-domain">${escapeHtml(host)}</span>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
preview.innerHTML = favorites.map(renderPreviewCard).join('') || '<div class="favorites-empty">Die Vorschau erscheint hier.</div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
editor.innerHTML = favorites.map((favorite, index) => `
|
||||
<article class="favorite-edit-card" data-index="${index}">
|
||||
<div class="favorite-edit-head">
|
||||
<strong>${escapeHtml(favorite.name || 'Neuer Link')}</strong>
|
||||
<div class="favorite-order-actions">
|
||||
<button class="ghost-button mini" data-action="up" type="button" ${index === 0 ? 'disabled' : ''}>Hoch</button>
|
||||
<button class="ghost-button mini" data-action="down" type="button" ${index === favorites.length - 1 ? 'disabled' : ''}>Runter</button>
|
||||
<button class="ghost-button mini danger-button" data-action="remove" type="button">Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorite-form-grid">
|
||||
<label>Beschriftung<input data-field="name" value="${escapeHtml(favorite.name)}" placeholder="YouTube"></label>
|
||||
<label>Link<input data-field="url" value="${escapeHtml(favorite.url)}" placeholder="https://youtube.com"></label>
|
||||
<label>Gruppe<input data-field="group" value="${escapeHtml(favorite.group)}" placeholder="Privat"></label>
|
||||
<label>Beschreibung<input data-field="description" value="${escapeHtml(favorite.description)}" placeholder="Optionaler Untertitel"></label>
|
||||
<label>Farbe<input data-field="color" type="color" value="${escapeHtml(favorite.color || '#67e8f9')}"></label>
|
||||
<label>Icon<select data-field="icon">${iconOptions(favorite.icon || 'star')}</select></label>
|
||||
<label class="favorite-form-wide">Logo URL<input data-field="logoUrl" value="${escapeHtml(favorite.logoUrl)}" placeholder="https://.../logo.png"></label>
|
||||
</div>
|
||||
</article>
|
||||
`).join('') || '<div class="favorites-empty">Noch keine Favoriten angelegt.</div>';
|
||||
|
||||
renderPreview();
|
||||
|
||||
editor.querySelectorAll('[data-field]').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
syncFromInputs();
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
|
||||
editor.querySelectorAll('[data-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const index = Number(button.closest('.favorite-edit-card').dataset.index);
|
||||
if (button.dataset.action === 'up') moveFavorite(index, -1);
|
||||
if (button.dataset.action === 'down') moveFavorite(index, 1);
|
||||
if (button.dataset.action === 'remove') removeFavorite(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
const response = await fetch('/api/favorites', {
|
||||
cache: 'no-store',
|
||||
headers: MischLabsAuth.authHeader()
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
|
||||
userBox.textContent = `Angemeldet als ${payload.user?.name || MischLabsAuth.displayName()}`;
|
||||
render();
|
||||
}
|
||||
|
||||
async function saveFavorites() {
|
||||
syncFromInputs();
|
||||
const response = await fetch('/api/favorites', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...MischLabsAuth.authHeader()
|
||||
},
|
||||
body: JSON.stringify({ favorites })
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
favorites = payload.favorites || [];
|
||||
render();
|
||||
userBox.textContent = 'Favoriten gespeichert.';
|
||||
}
|
||||
|
||||
function updateAuthUi() {
|
||||
const authenticated = MischLabsAuth.isAuthenticated();
|
||||
loginButton.hidden = authenticated;
|
||||
logoutButton.hidden = !authenticated;
|
||||
loginPanel.hidden = authenticated;
|
||||
panel.hidden = !authenticated;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
await MischLabsAuth.handleCallback();
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
}
|
||||
|
||||
updateAuthUi();
|
||||
if (MischLabsAuth.isAuthenticated()) {
|
||||
await loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
loginButton.addEventListener('click', () => MischLabsAuth.login());
|
||||
logoutButton.addEventListener('click', () => MischLabsAuth.logout());
|
||||
addButton.addEventListener('click', () => {
|
||||
syncFromInputs();
|
||||
favorites.push(createDefaultFavorite());
|
||||
render();
|
||||
});
|
||||
saveButton.addEventListener('click', () => {
|
||||
saveFavorites().catch((error) => {
|
||||
userBox.textContent = `Speichern fehlgeschlagen: ${error.message}`;
|
||||
});
|
||||
});
|
||||
reloadButton.addEventListener('click', () => {
|
||||
loadFavorites().catch((error) => {
|
||||
userBox.textContent = `Laden fehlgeschlagen: ${error.message}`;
|
||||
});
|
||||
});
|
||||
|
||||
boot().catch((error) => {
|
||||
message.textContent = `Favoritenseite konnte nicht starten: ${error.message}`;
|
||||
});
|
||||
BIN
icons/app-icon-source.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
icons/apple-touch-icon-v2.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
icons/apple-touch-icon-v4.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
icons/favicon-192-v4.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
icons/favicon-32-v4.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/favicon-48-v4.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
icons/favicon-512-v4.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
icons/maskable-512-v4.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
271
index.html
@@ -3,171 +3,144 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MischLabs - Services</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<title>MischLabs - Dashboard</title>
|
||||
<meta name="description" content="MischLabs service dashboard for self-hosted apps.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="MischLabs">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=4">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=4">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
|
||||
<link rel="stylesheet" href="style.css?v=34">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow bg-glow-1"></div>
|
||||
<div class="bg-glow bg-glow-2"></div>
|
||||
|
||||
<header>
|
||||
<div class="logo">
|
||||
<span class="logo-icon">⚙</span>
|
||||
<h1>Misch<span>Labs</span></h1>
|
||||
<header class="shell hero">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
|
||||
<div>
|
||||
<p class="eyebrow">Self-hosted dashboard</p>
|
||||
<h1>MischLabs</h1>
|
||||
<p class="subtitle">Die Selfhost-Werkstatt</p>
|
||||
</div>
|
||||
<p class="subtitle">Self-Hosted Services</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta" aria-label="Systemuebersicht">
|
||||
<div class="metric">
|
||||
<span class="metric-value" id="serviceCount">9</span>
|
||||
<span class="metric-label">Dienste</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-value" id="favoriteCount">--</span>
|
||||
<span class="metric-label">Favoriten</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid">
|
||||
|
||||
<a href="https://drive.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #4285f4;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<main class="shell">
|
||||
<section class="control-bar" aria-label="Dienste filtern">
|
||||
<label class="search-box">
|
||||
<span class="search-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<path d="m20 20-3.5-3.5"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<input id="serviceSearch" type="search" placeholder="Dienst suchen" autocomplete="off">
|
||||
<div class="dashboard-menu">
|
||||
<button class="dashboard-menu-button" id="dashboardMenuButton" type="button" aria-label="Menue oeffnen" aria-expanded="false" aria-controls="dashboardMenuPanel">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<nav class="dashboard-menu-panel" id="dashboardMenuPanel" aria-label="Dashboard Aktionen" hidden>
|
||||
<span class="dashboard-menu-user" id="dashboardUserBadge" hidden></span>
|
||||
<a class="dashboard-menu-item" id="favoritesLink" href="/favorites.html" hidden>Favoriten verwalten</a>
|
||||
<button class="dashboard-menu-item" id="dashboardLoginButton" type="button">SSO Login</button>
|
||||
<button class="dashboard-menu-item is-danger" id="dashboardLogoutButton" type="button" hidden>Abmelden</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Drive</h2>
|
||||
<p>Cloud-Speicher</p>
|
||||
</div>
|
||||
<span class="card-domain">drive.mischlabs.de</span>
|
||||
</a>
|
||||
</label>
|
||||
|
||||
<a href="https://git.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #f05033;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/><line x1="1.05" y1="12" x2="7" y2="12"/><line x1="17.01" y1="12" x2="22.96" y2="12"/>
|
||||
<path d="M12 1.05V7"/><path d="M12 17.01v5.95"/>
|
||||
</svg>
|
||||
<div class="view-toggle" role="tablist" aria-label="Ansicht">
|
||||
<button class="view-option is-active" type="button" data-view="all">Alles</button>
|
||||
<button class="view-option" type="button" data-view="services">Dienste</button>
|
||||
<button class="view-option" type="button" data-view="favorites">Favoriten</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Git</h2>
|
||||
<p>Code-Repositories</p>
|
||||
</div>
|
||||
<span class="card-domain">git.mischlabs.de</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<a href="https://movies.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #a855f7;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
</svg>
|
||||
<!-- Compact categories filter options placed directly above the service grid -->
|
||||
<div id="categoryFilters" class="category-filters">
|
||||
<div class="segments" role="tablist" aria-label="Kategorien">
|
||||
<button class="segment is-active" type="button" data-filter="all">Alle</button>
|
||||
<button class="segment" type="button" data-filter="access">Zugang</button>
|
||||
<button class="segment" type="button" data-filter="media">Medien</button>
|
||||
<button class="segment" type="button" data-filter="work">Arbeit</button>
|
||||
<button class="segment" type="button" data-filter="personal">Persoenlich</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Movies</h2>
|
||||
<p>Filme & Serien</p>
|
||||
</div>
|
||||
<span class="card-domain">movies.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
<a href="https://audiobook.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #22c55e;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
|
||||
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Audiobooks</h2>
|
||||
<p>Hoerbuecher</p>
|
||||
</div>
|
||||
<span class="card-domain">audiobook.mischlabs.de</span>
|
||||
</a>
|
||||
<div id="dashboardSections"></div>
|
||||
<div id="favoritesSections"></div>
|
||||
|
||||
<a href="https://foodlabs.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #f97316;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8h1a4 4 0 0 1 0 8h-1"/>
|
||||
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/>
|
||||
<line x1="6" y1="1" x2="6" y2="4"/>
|
||||
<line x1="10" y1="1" x2="10" y2="4"/>
|
||||
<line x1="14" y1="1" x2="14" y2="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>FoodLabs</h2>
|
||||
<p>Rezepte</p>
|
||||
</div>
|
||||
<span class="card-domain">foodlabs.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
<a href="https://password.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #eab308;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<circle cx="12" cy="16" r="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Passwords</h2>
|
||||
<p>Passwort-Manager</p>
|
||||
</div>
|
||||
<span class="card-domain">password.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
<a href="https://docs.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #06b6d4;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Docs</h2>
|
||||
<p>Dokumentation</p>
|
||||
</div>
|
||||
<span class="card-domain">docs.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
<a href="https://michess.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #10b981;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 16l-1 4h10l-1-4"/>
|
||||
<path d="M6.5 16h11"/>
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 1.1-.9 2-2 3-1.1-1-2-1.9-2-3a2 2 0 0 1 2-2z"/>
|
||||
<path d="M9 7L6 10.5c-.6.7-.2 1.5.7 1.5h10.6c.9 0 1.3-.8.7-1.5L15 7"/>
|
||||
<path d="M9 12v4"/>
|
||||
<path d="M15 12v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>MiChess</h2>
|
||||
<p>Schach</p>
|
||||
</div>
|
||||
<span class="card-domain">michess.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
<a href="https://tom.mischlabs.de" class="card" target="_blank" rel="noopener">
|
||||
<div class="card-icon" style="--accent: #ec4899;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>Tom</h2>
|
||||
<p>Persoenliche Seite</p>
|
||||
</div>
|
||||
<span class="card-domain">tom.mischlabs.de</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 MischLabs — Self-Hosted with care</p>
|
||||
<footer class="shell footer">
|
||||
<span>MischLabs</span>
|
||||
<span id="lastChecked">Status wird im Hintergrund geprueft.</span>
|
||||
</footer>
|
||||
|
||||
<!-- Peeking Bongo Cat Easter Egg -->
|
||||
<div class="peeking-cat" id="peekingCat" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 100" width="120" height="100">
|
||||
<!-- Left Ear -->
|
||||
<path class="cat-ear cat-ear-left" d="M 25 50 L 10 15 L 45 35 Z" fill="#ffffff" stroke="#1e293b" stroke-width="3" stroke-linejoin="round" />
|
||||
<path class="cat-ear cat-ear-left-inner" d="M 28 45 L 17 21 L 40 34 Z" fill="#fecdd3" />
|
||||
|
||||
<!-- Right Ear -->
|
||||
<path class="cat-ear cat-ear-right" d="M 95 50 L 110 15 L 75 35 Z" fill="#ffffff" stroke="#1e293b" stroke-width="3" stroke-linejoin="round" />
|
||||
<path class="cat-ear cat-ear-right-inner" d="M 92 45 L 103 21 L 80 34 Z" fill="#fecdd3" />
|
||||
|
||||
<!-- Face/Head -->
|
||||
<path class="cat-head" d="M 15 70 C 15 25, 105 25, 105 70 L 15 70 Z" fill="#ffffff" stroke="#1e293b" stroke-width="3" stroke-linejoin="round" />
|
||||
|
||||
<!-- Cheeks -->
|
||||
<ellipse class="cat-blush" cx="28" cy="58" rx="8" ry="5" fill="#fca5a5" opacity="0.6" />
|
||||
<ellipse class="cat-blush" cx="92" cy="58" rx="8" ry="5" fill="#fca5a5" opacity="0.6" />
|
||||
|
||||
<!-- Eyes -->
|
||||
<g class="cat-eye-group">
|
||||
<ellipse class="cat-eye cat-eye-l" cx="42" cy="50" rx="4" ry="5" fill="#1e293b" />
|
||||
<circle class="cat-eye-shine cat-eye-shine-l" cx="40" cy="48" r="1.2" fill="#ffffff" />
|
||||
</g>
|
||||
<g class="cat-eye-group">
|
||||
<ellipse class="cat-eye cat-eye-r" cx="78" cy="50" rx="4" ry="5" fill="#1e293b" />
|
||||
<circle class="cat-eye-shine cat-eye-shine-r" cx="76" cy="48" r="1.2" fill="#ffffff" />
|
||||
</g>
|
||||
|
||||
<!-- Nose/Mouth -->
|
||||
<path class="cat-mouth" d="M 57 54 Q 60 57 63 54 Q 66 57 69 54" fill="none" stroke="#1e293b" stroke-width="2.5" stroke-linecap="round" />
|
||||
|
||||
<!-- Whiskers -->
|
||||
<line class="cat-whisker" x1="12" y1="52" x2="3" y2="50" stroke="#1e293b" stroke-width="2" stroke-linecap="round" />
|
||||
<line class="cat-whisker" x1="11" y1="58" x2="2" y2="58" stroke="#1e293b" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<line class="cat-whisker" x1="108" y1="52" x2="117" y2="50" stroke="#1e293b" stroke-width="2" stroke-linecap="round" />
|
||||
<line class="cat-whisker" x1="109" y1="58" x2="118" y2="58" stroke="#1e293b" stroke-width="2" stroke-linecap="round" />
|
||||
|
||||
<!-- Paws -->
|
||||
<path class="cat-paw cat-paw-left" d="M 30 70 C 30 58, 46 58, 46 70 Z" fill="#ffffff" stroke="#1e293b" stroke-width="3" stroke-linejoin="round" />
|
||||
<path class="cat-paw cat-paw-right" d="M 74 70 C 74 58, 90 58, 90 70 Z" fill="#ffffff" stroke="#1e293b" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script src="/auth.js?v=2" defer></script>
|
||||
<script src="/app.js?v=40" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
33
manifest.webmanifest
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "MischLabs",
|
||||
"short_name": "MischLabs",
|
||||
"description": "Self-hosted service dashboard for MischLabs.",
|
||||
"lang": "de",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#0f172a",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/favicon-192-v4.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/favicon-512-v4.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-512-v4.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
nginx.conf
@@ -9,6 +9,36 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
}
|
||||
|
||||
location = /services.json {
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
default_type application/javascript;
|
||||
expires off;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
}
|
||||
|
||||
# Matrix Server Delegation (.well-known)
|
||||
location /.well-known/matrix/server {
|
||||
default_type application/json;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
return 200 '{"m.server": "matrix.mischlabs.de:443"}';
|
||||
}
|
||||
|
||||
# Matrix Client Delegation (.well-known)
|
||||
location /.well-known/matrix/client {
|
||||
default_type application/json;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
return 200 '{"m.homeserver":{"base_url":"https://matrix.mischlabs.de"},"org.matrix.msc3575.proxy":{"url":"https://matrix.mischlabs.de"}}';
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
|
||||
28
offline.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MischLabs - Offline</title>
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v=4">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=4">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
|
||||
<link rel="stylesheet" href="/style.css?v=30">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell hero">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
|
||||
<div>
|
||||
<p class="eyebrow">Offline</p>
|
||||
<h1>MischLabs</h1>
|
||||
<p class="subtitle">Die App ist installiert, aber die Dienste sind gerade nicht erreichbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
129
services.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"id": "access",
|
||||
"label": "Zugang",
|
||||
"title": "Accounts & Sicherheit"
|
||||
},
|
||||
{
|
||||
"id": "media",
|
||||
"label": "Medien",
|
||||
"title": "Bibliothek & Streaming"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"label": "Arbeit",
|
||||
"title": "Code, Projekte & Spiel"
|
||||
},
|
||||
{
|
||||
"id": "personal",
|
||||
"label": "Persoenlich",
|
||||
"title": "Portfolio & private Projekte"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"id": "sso",
|
||||
"name": "Single Sign-On",
|
||||
"description": "Account, Sitzungen und Sicherheit",
|
||||
"domain": "auth.mischlabs.de",
|
||||
"url": "https://auth.mischlabs.de/realms/mischlabs/account",
|
||||
"category": "access",
|
||||
"accent": "#f59e0b",
|
||||
"icon": "shield",
|
||||
"keywords": "single sign-on keycloak account login auth"
|
||||
},
|
||||
{
|
||||
"id": "drive",
|
||||
"name": "Drive",
|
||||
"description": "Cloud-Speicher",
|
||||
"domain": "drive.mischlabs.de",
|
||||
"url": "https://drive.mischlabs.de",
|
||||
"mobileUrl": "nextcloud://openFiles",
|
||||
"category": "media",
|
||||
"accent": "#60a5fa",
|
||||
"icon": "folder",
|
||||
"keywords": "drive nextcloud cloud speicher dateien"
|
||||
},
|
||||
{
|
||||
"id": "passwords",
|
||||
"name": "Passwords",
|
||||
"description": "Passwort-Manager",
|
||||
"domain": "password.mischlabs.de",
|
||||
"url": "https://password.mischlabs.de",
|
||||
"mobileUrl": "bitwarden://",
|
||||
"category": "access",
|
||||
"accent": "#eab308",
|
||||
"icon": "lock",
|
||||
"keywords": "passwords vaultwarden passwort manager security"
|
||||
},
|
||||
{
|
||||
"id": "movies",
|
||||
"name": "Movies",
|
||||
"description": "Filme & Serien",
|
||||
"domain": "movies.mischlabs.de",
|
||||
"url": "https://movies.mischlabs.de",
|
||||
"mobileUrl": "jellyfin://",
|
||||
"category": "media",
|
||||
"accent": "#a78bfa",
|
||||
"icon": "film",
|
||||
"keywords": "movies jellyfin filme serien media streaming"
|
||||
},
|
||||
{
|
||||
"id": "audiobooks",
|
||||
"name": "Audiobooks",
|
||||
"description": "Hoerbuecher",
|
||||
"domain": "audiobook.mischlabs.de",
|
||||
"url": "https://audiobook.mischlabs.de",
|
||||
"mobileUrl": "audiobookshelf://",
|
||||
"category": "media",
|
||||
"accent": "#22c55e",
|
||||
"icon": "headphones",
|
||||
"keywords": "audiobooks audiobookshelf hoerbuecher audio"
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Books",
|
||||
"description": "Buecher & Bibliothek",
|
||||
"domain": "books.mischlabs.de",
|
||||
"url": "https://books.mischlabs.de",
|
||||
"category": "media",
|
||||
"accent": "#06b6d4",
|
||||
"icon": "book",
|
||||
"keywords": "books calibre calibre-web buecher bibliothek"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"description": "Code-Repositories",
|
||||
"domain": "git.mischlabs.de",
|
||||
"url": "https://git.mischlabs.de",
|
||||
"category": "work",
|
||||
"accent": "#f97316",
|
||||
"icon": "git",
|
||||
"keywords": "git gitea code repositories ci actions"
|
||||
},
|
||||
{
|
||||
"id": "michess",
|
||||
"name": "MiChess",
|
||||
"description": "Schach",
|
||||
"domain": "michess.mischlabs.de",
|
||||
"url": "https://michess.mischlabs.de",
|
||||
"category": "personal",
|
||||
"accent": "#10b981",
|
||||
"icon": "chess",
|
||||
"keywords": "michess schach game chess"
|
||||
},
|
||||
{
|
||||
"id": "tom",
|
||||
"name": "Tom",
|
||||
"description": "Photography",
|
||||
"domain": "tom.mischlabs.de",
|
||||
"url": "https://tom.mischlabs.de",
|
||||
"category": "personal",
|
||||
"accent": "#ec4899",
|
||||
"icon": "user",
|
||||
"keywords": "tom photography mischkomposition portfolio persoenlich fotos"
|
||||
}
|
||||
]
|
||||
}
|
||||
70
sw.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const CACHE_NAME = 'mischlabs-pwa-v27';
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favorites.html',
|
||||
'/offline.html',
|
||||
'/style.css?v=34',
|
||||
'/auth.js?v=2',
|
||||
'/app.js?v=40',
|
||||
'/favorites.js?v=1',
|
||||
'/services.json?v=1',
|
||||
'/manifest.webmanifest?v=4',
|
||||
'/favicon.ico?v=4',
|
||||
'/icons/favicon-32-v4.png',
|
||||
'/icons/favicon-192-v4.png',
|
||||
'/icons/favicon-512-v4.png',
|
||||
'/icons/apple-touch-icon-v4.png',
|
||||
'/icons/maskable-512-v4.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => (
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||
))
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not intercept or cache API requests
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match('/offline.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => (
|
||||
cached || fetch(request).then((response) => {
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseToCache));
|
||||
return response;
|
||||
})
|
||||
))
|
||||
);
|
||||
});
|
||||