Compare commits

..

76 Commits

Author SHA1 Message Date
Kroonk
366a563b1f docs: Update Brain.md to document Matrix Server & WhatsApp Integration Phase 1 & 2 2026-05-22 13:13:17 +02:00
Kroonk
88627711be feat: serve matrix .well-known endpoints directly from Node server.js 2026-05-22 12:30:23 +02:00
Kroonk
9927d57755 feat: add .well-known delegation endpoints for Matrix server and client 2026-05-22 12:27:13 +02:00
Kroonk
fb3c7a99f1 feat: Service-Editor mit vollem CRUD und Server-Speichern
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 16s
2026-05-22 00:43:25 +02:00
Kroonk
072fc9b9f9 Integrate dashboard hamburger menu inside search box and swap control-bar layout
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-22 00:32:12 +02:00
Kroonk
47d73a466b Fix CSS hidden specificity and add premium header user badge
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 14s
2026-05-22 00:29:03 +02:00
Kroonk
205cb1d948 feat: implement automatic SSO login redirect on admin panel, with logout protection
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-22 00:26:27 +02:00
Kroonk
8ebee64543 fix: bypass service worker cache for dynamic /api/ endpoints, resolving stale favorites cache
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-22 00:23:37 +02:00
Kroonk
b0bcc8abbd feat: replace online status metric with favorites counter, ordered Dienste first
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-22 00:20:59 +02:00
Kroonk
57b39f8771 content: change subtitle to Die Selfhost-Werkstatt
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-22 00:18:36 +02:00
Kroonk
6cea1d9de9 fix: remove dark control-bar background to fix black bar visual glitch
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-22 00:14:02 +02:00
Kroonk
0a72e63e84 style: remove PWA metric card and optimize category filters for mobile usability
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-22 00:10:41 +02:00
Kroonk
1cdd944aca fix: only render admin panel link for admins
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-22 00:04:56 +02:00
Kroonk
df0a5d5035 fix: move dashboard actions into compact menu
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-22 00:02:51 +02:00
Kroonk
ae775c4dcf feat: share SSO session with admin panel
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 23:55:18 +02:00
Kroonk
14505c2296 fix: polish dashboard view toggle styling
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 23:52:15 +02:00
Kroonk
9572b6116a feat: add dashboard view toggle
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 23:48:58 +02:00
Kroonk
4c7ef500a1 fix: limit mobile handoff delay to Nextcloud
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 23:20:11 +02:00
Kroonk
d40329ae7c feat: add SSO user favorites dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 23:18:23 +02:00
Kroonk
fb540813ff fix: avoid web fallback after app handoff
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 23:05:09 +02:00
Kroonk
705374cacd fix: use current logo for all favicons
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 14s
2026-05-21 22:59:55 +02:00
Kroonk
cde4035eb3 fix: use Nextcloud iOS deep link scheme
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 22:55:17 +02:00
Kroonk
4bb07122ac fix: avoid invalid Nextcloud browser login flow
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 22:48:02 +02:00
Kroonk
718d843df2 feat: open Nextcloud login flow on mobile
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 22:43:52 +02:00
Kroonk
60ed7d624e feat: automate Keycloak session settings config on dashboard startup
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 22:31:04 +02:00
Kroonk
0d4377aed2 feat: implement automatic smart mobile app launcher with custom schemes and web fallbacks
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 22:28:22 +02:00
Kroonk
e663534000 docs: document recent dashboard improvements and add global AI prompt guideline to Brain.md
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 10s
2026-05-21 22:08:38 +02:00
Kroonk
c79f856e31 style: apply premium blue-to-purple gradient to brand text logo
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 20:44:14 +02:00
Kroonk
0a044bbb92 feat: implement container logs modal, service diagnostics inspector, and directory sizer tool
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 18:49:59 +02:00
Kroonk
6ff401ad74 feat: add consolidated Übersicht (Overview) dashboard tab with real-time mini-cards
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 18:41:11 +02:00
Kroonk
32bed2d65e fix: bump css and js version parameters to force cache busting
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 18:36:21 +02:00
Kroonk
223f5abf78 feat: add hamburger menu navigation drawer and tabbed views for admin sections
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 18:32:30 +02:00
Kroonk
28bf2c5dfe style: wrap service editor in collapsible card and make it narrower
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 18:26:40 +02:00
Kroonk
b3c1fbc9bd feat: fix watchtower manual run stuck states in both client and server
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 18:25:27 +02:00
Kroonk
76b7a96673 feat: make ops cards collapsible, hide notice and user display, increase button sizes
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 18:21:47 +02:00
Kroonk
995a397b2e feat: turn status items into mini clickable cards with pill-shaped status LEDs
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 18:13:21 +02:00
Kroonk
4cb0e89e92 style: minimalist design for services status board with tooltip and horizontal traffic-light bars
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 18:09:31 +02:00
Kroonk
e603bdc3be feat: Add premium server-side Dienste-Status (Realtime) Board to admin panel
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 18:04:53 +02:00
Kroonk
d4f6e56ff0 fix: Bump CSS and JS version to clear browser and service worker cache
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 17:57:27 +02:00
Kroonk
ddca0ae831 fix: Parse both JSON and text formatted logs in parseWatchtower
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 17:53:41 +02:00
Kroonk
ae116f52f9 style: Remove card hover peek raccoon
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 17:48:53 +02:00
Kroonk
8946cbecf6 feat: Add peeking Bongo Cat Easter Egg with micro-animations
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 17:41:28 +02:00
Kroonk
e9e589ddcc Auto-refresh Watchtower status
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 17:30:23 +02:00
Kroonk
2af7e65d4a Simplify dashboard service layout
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 17:27:31 +02:00
Kroonk
fddb2e3544 Add temporary danger restart mode
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 17:21:52 +02:00
Kroonk
d62224366b Fix admin ops status refresh
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 17:16:58 +02:00
Kroonk
5b252f17fd Clean up manual Watchtower containers
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 17:07:28 +02:00
Kroonk
c3ac1be083 Show manual Watchtower run status
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 16:49:41 +02:00
Kroonk
e98130d274 Add manual Watchtower trigger
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-21 16:43:59 +02:00
Kroonk
10ad368214 Add admin container restart controls
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 16:40:52 +02:00
Kroonk
ea52f223cd Improve ops status diagnostics
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 16:26:38 +02:00
Kroonk
29b3b623eb Add NAS status proxy
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
2026-05-21 16:22:34 +02:00
Kroonk
f0674cda37 Update dashboard service categories
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 10s
2026-05-21 16:04:31 +02:00
Kroonk
8f2b4faad9 Handle browser-blocked status checks
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 12s
2026-05-21 15:40:50 +02:00
Kroonk
2125601e56 Add SSO admin console for dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 15:25:51 +02:00
Kroonk
470220b8f4 Rework MischLabs into service dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 15:11:52 +02:00
Kroonk
0c3838da8a Bump iOS PWA icon filename
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 10s
2026-05-21 14:35:23 +02:00
Kroonk
325c30414c Use MischLabs artwork for PWA icons
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 14:07:19 +02:00
Kroonk
1ecbcc38ae Add PWA support for MischLabs
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
2026-05-21 13:56:14 +02:00
Kroonk
81ddb781ca Improve landing page mobile performance
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 9s
2026-05-20 18:24:11 +02:00
Kroonk
75b8a66c62 Refine cat hover expression
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 10s
2026-05-20 18:20:52 +02:00
Kroonk
6bb915299f Place cat peek behind cards
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 9s
2026-05-20 18:15:39 +02:00
Kroonk
271612af32 Add cat peek hover to service cards
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 9s
2026-05-20 18:09:42 +02:00
Kroonk
c0680700a3 Optimize: Migrate mischlabs fully to Gitea Local Package Registry
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s
2026-05-20 16:43:54 +02:00
Kroonk
bf404e34c3 Update: Set mischlabs image to GHCR repository in docker-compose
All checks were successful
Build & Push Docker Image to GHCR / build-and-push (push) Successful in 14s
2026-05-20 16:37:21 +02:00
Kroonk
c132cd372d Optimize: Remove Buildx to fix 5-minute hang and use native docker build
All checks were successful
Build & Push Docker Image to GHCR / build-and-push (push) Successful in 21s
2026-05-20 16:31:39 +02:00
Kroonk
036196abe1 Fix: Add Set up Docker Buildx step
Some checks failed
Build & Push Docker Image to GHCR / build-and-push (push) Failing after 5m10s
2026-05-20 16:25:59 +02:00
Kroonk
22411b4cc3 Fix: Explicitly use Bourne shell (sh) for Install Docker CLI step
Some checks failed
Build & Push Docker Image to GHCR / build-and-push (push) Failing after 13s
2026-05-20 16:25:02 +02:00
Kroonk
13e1f35ae0 Fix: Add robust Docker CLI installation to Gitea workflow using static binaries
Some checks failed
Build & Push Docker Image to GHCR / build-and-push (push) Failing after 5s
2026-05-20 16:23:48 +02:00
Kroonk
7ae5007fb5 Add Gitea Actions workflow to build and push docker image
Some checks failed
Build & Push Docker Image to GHCR / build-and-push (push) Failing after 10s
2026-05-20 16:20:44 +02:00
Kroonk
50d162af57 Docs-Kachel zu Books.mischlabs.de geaendert 2026-05-20 15:40:52 +02:00
Kroonk
9254e9d631 Fix SSO banner: force horizontal layout with smaller elements
Use higher specificity selectors to override base card styles.
Smaller icon, font sizes, and tighter padding for compact banner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:46:47 +02:00
Kroonk
6d9dca95f0 Add cache-busting version to CSS link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:44:49 +02:00
Kroonk
e04632a3e1 Reduce auth banner height and normalize spacing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:36:35 +02:00
Kroonk
0f35698eb8 Add auth.mischlabs.de as wide banner card above service grid
Horizontal banner layout with shield icon, spanning full grid width.
Responsive: collapses to vertical on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:34:43 +02:00
Kroonk
28f5654060 Remove FoodLabs/Mealie tile (was only a test service)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:33:32 +02:00
32 changed files with 6919 additions and 371 deletions

59
.github/workflows/docker-build.yml vendored Normal file
View 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
View File

@@ -3,11 +3,15 @@
## Projektziel ## 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. 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 ## Status
- [x] Git-Repo initialisiert und Remote verbunden (https://git.mischlabs.de/MrDiderot/mischlabs.git) - [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] Dienste-Liste vom Screenshot erfasst (9 Services inkl. MiChess)
- [x] Landing Page Design und Implementierung (index.html + style.css) - [x] Landing Page Design und Implementierung (index.html + style.css)
- [x] Docker-Setup (Dockerfile, docker-compose.yml, nginx.conf) - [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] Push zum Remote
- [x] MiChess-Kachel hinzugefuegt (Commit lokal, Push steht aus wg. Auth-Fehler) - [x] MiChess-Kachel hinzugefuegt (Commit lokal, Push steht aus wg. Auth-Fehler)
- [ ] Push des MiChess-Commits (Git-Auth-Token erneuern) - [ ] 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 - Subdomain: auth.mischlabs.de
- Siehe auth/Brain.md fuer Details - Siehe auth/Brain.md fuer Details
## Dienste-Uebersicht (9 Stueck) ## Dienste-Uebersicht (8 Stueck)
| # | Subdomain | Beschreibung | Farbe/Accent | | # | Subdomain | Beschreibung | Farbe/Accent |
|---|-----------|-------------|--------------| |---|-----------|-------------|--------------|
| 1 | drive.mischlabs.de | Cloud-Speicher | #4285f4 (Blau) | | 1 | drive.mischlabs.de | Cloud-Speicher | #4285f4 (Blau) |
| 2 | git.mischlabs.de | Code-Repositories | #f05033 (Rot) | | 2 | git.mischlabs.de | Code-Repositories | #f05033 (Rot) |
| 3 | movies.mischlabs.de | Filme & Serien | #a855f7 (Lila) | | 3 | movies.mischlabs.de | Filme & Serien | #a855f7 (Lila) |
| 4 | audiobook.mischlabs.de | Hoerbuecher | #22c55e (Gruen) | | 4 | audiobook.mischlabs.de | Hoerbuecher | #22c55e (Gruen) |
| 5 | foodlabs.mischlabs.de | Rezepte | #f97316 (Orange) | | 5 | password.mischlabs.de | Passwort-Manager | #eab308 (Gelb) |
| 6 | password.mischlabs.de | Passwort-Manager | #eab308 (Gelb) | | 6 | docs.mischlabs.de | Dokumentation | #06b6d4 (Cyan) |
| 7 | docs.mischlabs.de | Dokumentation | #06b6d4 (Cyan) | | 7 | michess.mischlabs.de | Schach | #10b981 (Gruen) |
| 8 | michess.mischlabs.de | Schach | #10b981 (Gruen) | | 8 | tom.mischlabs.de | Persoenliche Seite | #ec4899 (Pink) |
| 9 | tom.mischlabs.de | Persoenliche Seite | #ec4899 (Pink) |
## Technische Entscheidungen ## 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) - **nginx:alpine** als Base-Image - extrem leichtgewichtig (~5MB)
- **Google Fonts (Inter)** fuer moderne Typografie - **Google Fonts (Inter)** fuer moderne Typografie
- **Inline SVG Icons** - keine externen Dependencies - **Inline SVG Icons** - keine externen Dependencies
- **CSS Grid** mit `auto-fill` + `minmax(240px, 1fr)` fuer responsives Layout - **CSS Grid** mit `auto-fill` + `minmax(240px, 1fr)` fuer responsives Layout
- **Dark Theme** mit subtilen Glow-Effekten - **Dark Theme** mit subtilen Glow-Effekten
- **Staggered Animations**: Kacheln faden nacheinander ein (9 Stueck) - **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 ## Docker-Setup
- **Container-Name**: `mischlabs` - **Container-Name**: `mischlabs`
@@ -52,6 +59,14 @@ Eine ansprechende Landing Page fuer mischlabs.de, die alle Dienste als Kacheln m
mischlabs/ mischlabs/
index.html - Haupt-HTML mit allen 9 Service-Kacheln index.html - Haupt-HTML mit allen 9 Service-Kacheln
style.css - Komplettes Styling (Dark Theme, Grid, Animationen) 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 Dockerfile - nginx:alpine mit kopierten Static Files
docker-compose.yml - Container-Definition (Name: mischlabs, Port 8085) docker-compose.yml - Container-Definition (Name: mischlabs, Port 8085)
nginx.conf - Server-Konfiguration, Caching, Security-Headers nginx.conf - Server-Konfiguration, Caching, Security-Headers
@@ -73,3 +88,118 @@ mischlabs/
- Git-Push-Fehler aufgetreten (Authentication failed) - Token muss erneuert werden - 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 - 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 - 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.

View File

@@ -1,7 +1,29 @@
FROM nginx:alpine FROM node:22-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf WORKDIR /app
COPY index.html /usr/share/nginx/html/
COPY style.css /usr/share/nginx/html/ 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 EXPOSE 80
CMD ["node", "server.js"]

249
admin.html Normal file
View 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 &amp; 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">&times;</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 &amp; 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">&times;</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">&times;</button>
</div>
<div class="modal-body" id="serviceModalContent">
<!-- Dynamically populated details -->
</div>
</div>
</div>
<script src="/admin.js?v=31" defer></script>
</body>
</html>

1493
admin.js Normal file

File diff suppressed because it is too large Load Diff

669
app.js Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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
View 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
View 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

View File

@@ -1,7 +1,15 @@
services: services:
mischlabs: mischlabs:
build: . image: git.mischlabs.de/mrdiderot/mischlabs:latest
container_name: mischlabs container_name: mischlabs
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8085:80" - "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

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

73
favorites.html Normal file
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
icons/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
icons/favicon-192-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
icons/favicon-32-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
icons/favicon-48-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
icons/favicon-512-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

BIN
icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

BIN
icons/maskable-512-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

BIN
icons/maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

View File

@@ -3,171 +3,144 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MischLabs - Services</title> <title>MischLabs - Dashboard</title>
<link rel="stylesheet" href="style.css"> <meta name="description" content="MischLabs service dashboard for self-hosted apps.">
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="theme-color" content="#0f172a">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <meta name="apple-mobile-web-app-capable" content="yes">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <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> </head>
<body> <body>
<div class="bg-glow bg-glow-1"></div> <header class="shell hero">
<div class="bg-glow bg-glow-2"></div> <div class="brand">
<img class="brand-mark" src="/icons/app-icon-source.png" alt="" width="72" height="72">
<header> <div>
<div class="logo"> <p class="eyebrow">Self-hosted dashboard</p>
<span class="logo-icon">&#9881;</span> <h1>MischLabs</h1>
<h1>Misch<span>Labs</span></h1> <p class="subtitle">Die Selfhost-Werkstatt</p>
</div>
</div> </div>
<p class="subtitle">Self-Hosted Services</p>
<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> </header>
<main> <main class="shell">
<div class="grid"> <section class="control-bar" aria-label="Dienste filtern">
<label class="search-box">
<a href="https://drive.mischlabs.de" class="card" target="_blank" rel="noopener"> <span class="search-icon" aria-hidden="true">
<div class="card-icon" style="--accent: #4285f4;"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <circle cx="11" cy="11" r="7"></circle>
<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 d="m20 20-3.5-3.5"></path>
</svg> </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>
<div class="card-content"> </label>
<h2>Drive</h2>
<p>Cloud-Speicher</p>
</div>
<span class="card-domain">drive.mischlabs.de</span>
</a>
<a href="https://git.mischlabs.de" class="card" target="_blank" rel="noopener"> <div class="view-toggle" role="tablist" aria-label="Ansicht">
<div class="card-icon" style="--accent: #f05033;"> <button class="view-option is-active" type="button" data-view="all">Alles</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <button class="view-option" type="button" data-view="services">Dienste</button>
<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"/> <button class="view-option" type="button" data-view="favorites">Favoriten</button>
<path d="M12 1.05V7"/><path d="M12 17.01v5.95"/> </div>
</svg> </section>
</div>
<div class="card-content">
<h2>Git</h2>
<p>Code-Repositories</p>
</div>
<span class="card-domain">git.mischlabs.de</span>
</a>
<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>
</div>
<div class="card-content">
<h2>Movies</h2>
<p>Filme &amp; 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>
<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>
<!-- 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> </div>
<div id="dashboardSections"></div>
<div id="favoritesSections"></div>
<p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p>
</main> </main>
<footer> <footer class="shell footer">
<p>&copy; 2026 MischLabs &mdash; Self-Hosted with care</p> <span>MischLabs</span>
<span id="lastChecked">Status wird im Hintergrund geprueft.</span>
</footer> </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> </body>
</html> </html>

33
manifest.webmanifest Normal file
View 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"
}
]
}

View File

@@ -9,6 +9,36 @@ server {
try_files $uri $uri/ /index.html; 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 # Cache static assets
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d; expires 7d;

28
offline.html Normal file
View 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>

1385
server.js Normal file

File diff suppressed because it is too large Load Diff

129
services.json Normal file
View 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"
}
]
}

2095
style.css

File diff suppressed because it is too large Load Diff

70
sw.js Normal file
View 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;
})
))
);
});