Add SSO admin console for dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
This commit is contained in:
24
Brain.md
24
Brain.md
@@ -45,6 +45,7 @@ Aktueller Stand: Die Seite ist keine reine Linkliste mehr, sondern ein kompaktes
|
|||||||
- **PWA**: Manifest, Apple-Touch-Icon, App-Icons und Service Worker fuer installierbare iOS/Web-App
|
- **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
|
- **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
|
- **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`
|
||||||
@@ -59,6 +60,9 @@ 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
|
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
|
offline.html - Offline-Fallback fuer installierte PWA
|
||||||
manifest.webmanifest - PWA-Metadaten fuer installierbare App
|
manifest.webmanifest - PWA-Metadaten fuer installierbare App
|
||||||
sw.js - Schlanker Service Worker mit App-Shell-Cache
|
sw.js - Schlanker Service Worker mit App-Shell-Cache
|
||||||
@@ -105,8 +109,8 @@ mischlabs/
|
|||||||
- `icons/icon-512.png` (512x512)
|
- `icons/icon-512.png` (512x512)
|
||||||
- `icons/maskable-512.png` (512x512, maskable)
|
- `icons/maskable-512.png` (512x512, maskable)
|
||||||
- Service Worker:
|
- Service Worker:
|
||||||
- Cache-Name: `mischlabs-pwa-v3`
|
- Cache-Name: `mischlabs-pwa-v4`
|
||||||
- Cached die App-Shell (`/`, `index.html`, `offline.html`, `style.css?v=9`, `app.js?v=1`, Manifest und Icons)
|
- 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
|
- Navigationen fallen offline auf `offline.html` zurueck
|
||||||
- Dockerfile kopiert Manifest, Service Worker und Icons in das Nginx-Image.
|
- 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.
|
- `nginx.conf` liefert `manifest.webmanifest` mit `application/manifest+json` und `sw.js` mit harten No-Cache-Headern aus.
|
||||||
@@ -121,3 +125,19 @@ mischlabs/
|
|||||||
- Arbeit: Git, MiChess
|
- Arbeit: Git, MiChess
|
||||||
- Persoenlich: Tom
|
- Persoenlich: Tom
|
||||||
- `app.js` prueft Dienste im Hintergrund per `fetch(..., { mode: "no-cors" })`. Das ist bewusst nur ein Erreichbarkeitsindikator, kein vollwertiges Monitoring.
|
- `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. Fuer echte Fehlerdetails braucht es ebenfalls ein Backend.
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ FROM nginx:alpine
|
|||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
COPY admin.html /usr/share/nginx/html/
|
||||||
COPY offline.html /usr/share/nginx/html/
|
COPY offline.html /usr/share/nginx/html/
|
||||||
COPY style.css /usr/share/nginx/html/
|
COPY style.css /usr/share/nginx/html/
|
||||||
COPY app.js /usr/share/nginx/html/
|
COPY app.js /usr/share/nginx/html/
|
||||||
|
COPY admin.js /usr/share/nginx/html/
|
||||||
|
COPY services.json /usr/share/nginx/html/
|
||||||
COPY manifest.webmanifest /usr/share/nginx/html/
|
COPY manifest.webmanifest /usr/share/nginx/html/
|
||||||
COPY sw.js /usr/share/nginx/html/
|
COPY sw.js /usr/share/nginx/html/
|
||||||
COPY icons/ /usr/share/nginx/html/icons/
|
COPY icons/ /usr/share/nginx/html/icons/
|
||||||
|
|||||||
62
admin.html
Normal file
62
admin.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!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="stylesheet" href="/style.css?v=10">
|
||||||
|
</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" 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>
|
||||||
|
</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>Dienste & Kategorien</h2>
|
||||||
|
</div>
|
||||||
|
<div class="admin-actions compact">
|
||||||
|
<button class="ghost-button" id="checkAllButton" type="button">Status pruefen</button>
|
||||||
|
<button class="ghost-button" id="saveLocalButton" type="button">Lokal speichern</button>
|
||||||
|
<button class="ghost-button" id="resetLocalButton" type="button">Lokale Aenderungen verwerfen</button>
|
||||||
|
<button class="ghost-button" id="downloadConfigButton" type="button">JSON exportieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Hinweis: Ohne Backend speichert diese Adminseite Aenderungen lokal im Browser und kann eine neue `services.json` exportieren. Fuer globale Live-Aenderungen bauen wir als naechsten Schritt ein kleines Backend oder einen Gitea-Commit-Workflow.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-user" id="adminUser"></div>
|
||||||
|
<div class="admin-table" id="serviceEditor"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/admin.js?v=1" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
318
admin.js
Normal file
318
admin.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
const AUTHORITY = 'https://auth.mischlabs.de/realms/mischlabs';
|
||||||
|
const CLIENT_ID = 'mischlabs-admin';
|
||||||
|
const REDIRECT_URI = `${window.location.origin}/admin.html`;
|
||||||
|
const SCOPES = 'openid profile email';
|
||||||
|
const CONFIG_URL = '/services.json?v=1';
|
||||||
|
const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config';
|
||||||
|
const TOKEN_KEY = 'mischlabs.admin.tokens';
|
||||||
|
const PKCE_KEY = 'mischlabs.admin.pkce';
|
||||||
|
|
||||||
|
const loginButton = document.querySelector('#loginButton');
|
||||||
|
const logoutButton = document.querySelector('#logoutButton');
|
||||||
|
const loginPanel = document.querySelector('#loginPanel');
|
||||||
|
const adminPanel = document.querySelector('#adminPanel');
|
||||||
|
const loginMessage = document.querySelector('#loginMessage');
|
||||||
|
const adminUser = document.querySelector('#adminUser');
|
||||||
|
const serviceEditor = document.querySelector('#serviceEditor');
|
||||||
|
const checkAllButton = document.querySelector('#checkAllButton');
|
||||||
|
const saveLocalButton = document.querySelector('#saveLocalButton');
|
||||||
|
const resetLocalButton = document.querySelector('#resetLocalButton');
|
||||||
|
const downloadConfigButton = document.querySelector('#downloadConfigButton');
|
||||||
|
|
||||||
|
let config = null;
|
||||||
|
let tokenSet = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getStoredTokens() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(TOKEN_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTokens(tokens) {
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens() {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(PKCE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenValid(tokens) {
|
||||||
|
if (!tokens?.id_token) return false;
|
||||||
|
try {
|
||||||
|
const claims = decodeJwt(tokens.id_token);
|
||||||
|
return Date.now() < claims.exp * 1000;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const verifier = randomString();
|
||||||
|
const challenge = await createChallenge(verifier);
|
||||||
|
const state = randomString(32);
|
||||||
|
sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state }));
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = `${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) {
|
||||||
|
loginMessage.textContent = `Keycloak Fehler: ${error}`;
|
||||||
|
window.history.replaceState({}, document.title, REDIRECT_URI);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}');
|
||||||
|
if (!stored.verifier || stored.state !== state) {
|
||||||
|
loginMessage.textContent = 'SSO Antwort konnte nicht verifiziert werden.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
code,
|
||||||
|
code_verifier: stored.verifier
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
storeTokens(tokens);
|
||||||
|
window.history.replaceState({}, document.title, REDIRECT_URI);
|
||||||
|
} catch (error) {
|
||||||
|
loginMessage.textContent = `Token-Austausch fehlgeschlagen: ${error.message}. Keycloak Client/Web Origins pruefen.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorize(tokens) {
|
||||||
|
const claims = decodeJwt(tokens.id_token);
|
||||||
|
const username = String(claims.preferred_username || '').toLowerCase();
|
||||||
|
const email = String(claims.email || '').toLowerCase();
|
||||||
|
const allowed = username === 'mrdiderot' || email === 'mail.misch@pm.me';
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
loginMessage.textContent = `Angemeldet als ${claims.preferred_username || claims.email}, aber nicht fuer diese Adminseite freigeschaltet.`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const local = localStorage.getItem(LOCAL_CONFIG_KEY);
|
||||||
|
if (local) return JSON.parse(local);
|
||||||
|
|
||||||
|
const response = await fetch(CONFIG_URL, { cache: 'no-store' });
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryOptions(selected) {
|
||||||
|
return config.categories.map((category) => (
|
||||||
|
`<option value="${category.id}" ${category.id === selected ? 'selected' : ''}>${category.label}</option>`
|
||||||
|
)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
serviceEditor.innerHTML = config.services.map((service, index) => `
|
||||||
|
<article class="admin-row" data-index="${index}">
|
||||||
|
<div>
|
||||||
|
<strong>${service.name}</strong>
|
||||||
|
<span>${service.domain}</span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Kategorie
|
||||||
|
<select data-field="category">${categoryOptions(service.category)}</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
URL
|
||||||
|
<input data-field="url" value="${service.url}">
|
||||||
|
</label>
|
||||||
|
<button class="ghost-button check-one" type="button">Pruefen</button>
|
||||||
|
<div class="diagnostic">Noch nicht geprueft.</div>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
serviceEditor.querySelectorAll('select,input').forEach((input) => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const row = input.closest('.admin-row');
|
||||||
|
const service = config.services[Number(row.dataset.index)];
|
||||||
|
service[input.dataset.field] = input.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
serviceEditor.querySelectorAll('.check-one').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(url) {
|
||||||
|
const started = performance.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
mode: 'no-cors',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
detail: response.type === 'opaque'
|
||||||
|
? 'Erreichbar. HTTP-Details wegen Browser-CORS nicht lesbar.'
|
||||||
|
: `HTTP ${response.status}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
ms: Math.round(performance.now() - started),
|
||||||
|
detail: error.name === 'AbortError' ? 'Timeout nach 6 Sekunden.' : error.message
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRow(row) {
|
||||||
|
const service = config.services[Number(row.dataset.index)];
|
||||||
|
const diagnostic = row.querySelector('.diagnostic');
|
||||||
|
diagnostic.textContent = 'Pruefe...';
|
||||||
|
row.classList.remove('is-online', 'is-offline');
|
||||||
|
|
||||||
|
const result = await probe(service.url);
|
||||||
|
row.classList.toggle('is-online', result.ok);
|
||||||
|
row.classList.toggle('is-offline', !result.ok);
|
||||||
|
diagnostic.textContent = `${result.ok ? 'Online' : 'Fehler'} - ${result.ms} ms - ${result.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocal() {
|
||||||
|
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadConfig() {
|
||||||
|
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'services.json';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
await handleCallback();
|
||||||
|
tokenSet = getStoredTokens();
|
||||||
|
|
||||||
|
if (!isTokenValid(tokenSet)) {
|
||||||
|
loginPanel.hidden = false;
|
||||||
|
adminPanel.hidden = true;
|
||||||
|
loginButton.hidden = false;
|
||||||
|
logoutButton.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = authorize(tokenSet);
|
||||||
|
if (!claims) return;
|
||||||
|
|
||||||
|
config = await loadConfig();
|
||||||
|
loginPanel.hidden = true;
|
||||||
|
adminPanel.hidden = false;
|
||||||
|
loginButton.hidden = true;
|
||||||
|
logoutButton.hidden = false;
|
||||||
|
adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginButton.addEventListener('click', login);
|
||||||
|
logoutButton.addEventListener('click', () => {
|
||||||
|
const idToken = tokenSet?.id_token;
|
||||||
|
clearTokens();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
post_logout_redirect_uri: REDIRECT_URI
|
||||||
|
});
|
||||||
|
if (idToken) params.set('id_token_hint', idToken);
|
||||||
|
window.location.href = `${AUTHORITY}/protocol/openid-connect/logout?${params}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkAllButton.addEventListener('click', () => {
|
||||||
|
[...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
saveLocalButton.addEventListener('click', () => {
|
||||||
|
saveLocal();
|
||||||
|
window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
resetLocalButton.addEventListener('click', () => {
|
||||||
|
localStorage.removeItem(LOCAL_CONFIG_KEY);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadConfigButton.addEventListener('click', downloadConfig);
|
||||||
|
|
||||||
|
boot().catch((error) => {
|
||||||
|
loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;
|
||||||
|
});
|
||||||
|
|
||||||
144
app.js
144
app.js
@@ -1,15 +1,114 @@
|
|||||||
const cards = [...document.querySelectorAll('.service-card')];
|
const CONFIG_URL = '/services.json?v=1';
|
||||||
const sections = [...document.querySelectorAll('.section')];
|
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 activeFilter = 'all';
|
||||||
|
|
||||||
const searchInput = document.querySelector('#serviceSearch');
|
const searchInput = document.querySelector('#serviceSearch');
|
||||||
const segments = [...document.querySelectorAll('.segment')];
|
const segments = [...document.querySelectorAll('.segment')];
|
||||||
const emptyState = document.querySelector('#emptyState');
|
const emptyState = document.querySelector('#emptyState');
|
||||||
const onlineCount = document.querySelector('#onlineCount');
|
const onlineCount = document.querySelector('#onlineCount');
|
||||||
|
const serviceCount = document.querySelector('#serviceCount');
|
||||||
const lastChecked = document.querySelector('#lastChecked');
|
const lastChecked = document.querySelector('#lastChecked');
|
||||||
|
const dashboardSections = document.querySelector('#dashboardSections');
|
||||||
let activeFilter = 'all';
|
|
||||||
|
|
||||||
function normalize(value) {
|
function normalize(value) {
|
||||||
return value.toLowerCase().trim();
|
return String(value || '').toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 createServiceCard(service) {
|
||||||
|
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">
|
||||||
|
<h3>${service.name}</h3>
|
||||||
|
<p>${service.description}</p>
|
||||||
|
</div>
|
||||||
|
<span class="card-domain">${service.domain}</span>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard(nextConfig) {
|
||||||
|
dashboardSections.innerHTML = '';
|
||||||
|
serviceCount.textContent = String(nextConfig.services.length);
|
||||||
|
|
||||||
|
nextConfig.categories.forEach((category) => {
|
||||||
|
const services = nextConfig.services.filter((service) => service.category === category.id);
|
||||||
|
if (!services.length) return;
|
||||||
|
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.className = 'section';
|
||||||
|
section.dataset.category = category.id;
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">${category.label}</p>
|
||||||
|
<h2>${category.title}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="section-count">${services.length} ${services.length === 1 ? 'Dienst' : 'Dienste'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const grid = section.querySelector('.grid');
|
||||||
|
services.forEach((service) => grid.appendChild(createServiceCard(service)));
|
||||||
|
dashboardSections.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
cards = [...document.querySelectorAll('.service-card')];
|
||||||
|
sections = [...document.querySelectorAll('.section')];
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVisibility() {
|
function updateVisibility() {
|
||||||
@@ -77,6 +176,19 @@ async function refreshStatus() {
|
|||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleStatusRefresh() {
|
||||||
|
const run = () => refreshStatus().catch(() => {
|
||||||
|
onlineCount.textContent = '--';
|
||||||
|
lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
window.requestIdleCallback(run, { timeout: 2000 });
|
||||||
|
} else {
|
||||||
|
window.setTimeout(run, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
segments.forEach((segment) => {
|
segments.forEach((segment) => {
|
||||||
segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter));
|
segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter));
|
||||||
});
|
});
|
||||||
@@ -89,18 +201,14 @@ if ('serviceWorker' in navigator) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
loadConfig()
|
||||||
const run = () => refreshStatus().catch(() => {
|
.then((nextConfig) => {
|
||||||
|
config = nextConfig;
|
||||||
|
renderDashboard(config);
|
||||||
|
updateVisibility();
|
||||||
|
scheduleStatusRefresh();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dashboardSections.innerHTML = '<p class="empty-state">Dashboard-Konfiguration konnte nicht geladen werden.</p>';
|
||||||
onlineCount.textContent = '--';
|
onlineCount.textContent = '--';
|
||||||
lastChecked.textContent = 'Status aktuell nicht verfuegbar.';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ('requestIdleCallback' in window) {
|
|
||||||
window.requestIdleCallback(run, { timeout: 2000 });
|
|
||||||
} else {
|
|
||||||
window.setTimeout(run, 600);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateVisibility();
|
|
||||||
|
|
||||||
|
|||||||
186
index.html
186
index.html
@@ -13,7 +13,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v2.png">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v2.png">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
|
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
|
||||||
<link rel="stylesheet" href="style.css?v=9">
|
<link rel="stylesheet" href="style.css?v=10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="shell hero">
|
<header class="shell hero">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<span class="metric-label">Online</span>
|
<span class="metric-label">Online</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-value">9</span>
|
<span class="metric-value" id="serviceCount">9</span>
|
||||||
<span class="metric-label">Dienste</span>
|
<span class="metric-label">Dienste</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
@@ -63,185 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<div id="dashboardSections"></div>
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<p class="section-kicker">Zugang</p>
|
|
||||||
<h2>Accounts & Sicherheit</h2>
|
|
||||||
</div>
|
|
||||||
<span class="section-count">3 Dienste</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<a href="https://auth.mischlabs.de/realms/mischlabs/account" class="card card-wide service-card" target="_blank" rel="noopener" data-category="access" data-url="https://auth.mischlabs.de/realms/mischlabs/account" data-name="single sign-on keycloak account login auth">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #f59e0b;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Single Sign-On</h3>
|
|
||||||
<p>Account, Sitzungen und Sicherheit</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">auth.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://drive.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="access" data-url="https://drive.mischlabs.de" data-name="drive nextcloud cloud speicher dateien">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #60a5fa;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Drive</h3>
|
|
||||||
<p>Cloud-Speicher</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">drive.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://password.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="access" data-url="https://password.mischlabs.de" data-name="passwords vaultwarden passwort manager security">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #eab308;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Passwords</h3>
|
|
||||||
<p>Passwort-Manager</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">password.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<p class="section-kicker">Medien</p>
|
|
||||||
<h2>Bibliothek & Streaming</h2>
|
|
||||||
</div>
|
|
||||||
<span class="section-count">3 Dienste</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<a href="https://movies.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="media" data-url="https://movies.mischlabs.de" data-name="movies jellyfin filme serien media streaming">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #a78bfa;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Movies</h3>
|
|
||||||
<p>Filme & Serien</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">movies.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://audiobook.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="media" data-url="https://audiobook.mischlabs.de" data-name="audiobooks audiobookshelf hoerbuecher audio">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #22c55e;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Audiobooks</h3>
|
|
||||||
<p>Hoerbuecher</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">audiobook.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://books.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="media" data-url="https://books.mischlabs.de" data-name="books calibre calibre-web buecher bibliothek">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #06b6d4;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Books</h3>
|
|
||||||
<p>Buecher & Bibliothek</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">books.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<p class="section-kicker">Arbeit</p>
|
|
||||||
<h2>Code, Projekte & Spiel</h2>
|
|
||||||
</div>
|
|
||||||
<span class="section-count">3 Dienste</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<a href="https://git.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="work" data-url="https://git.mischlabs.de" data-name="git gitea code repositories ci actions">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #f97316;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Git</h3>
|
|
||||||
<p>Code-Repositories</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">git.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://michess.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="work" data-url="https://michess.mischlabs.de" data-name="michess schach game chess">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #10b981;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>MiChess</h3>
|
|
||||||
<p>Schach</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">michess.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://tom.mischlabs.de" class="card service-card" target="_blank" rel="noopener" data-category="personal" data-url="https://tom.mischlabs.de" data-name="tom photography mischkomposition portfolio persoenlich fotos">
|
|
||||||
<span class="status-dot" aria-label="Status wird geprueft"></span>
|
|
||||||
<div class="card-icon" style="--accent: #ec4899;">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<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>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="card-content">
|
|
||||||
<h3>Tom</h3>
|
|
||||||
<p>Photography</p>
|
|
||||||
</div>
|
|
||||||
<span class="card-domain">tom.mischlabs.de</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p>
|
<p class="empty-state" id="emptyState" hidden>Kein Dienst passt zur Suche.</p>
|
||||||
</main>
|
</main>
|
||||||
@@ -251,6 +73,6 @@
|
|||||||
<span id="lastChecked">Status wird im Hintergrund geprueft.</span>
|
<span id="lastChecked">Status wird im Hintergrund geprueft.</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/app.js?v=1" defer></script>
|
<script src="/app.js?v=2" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ server {
|
|||||||
add_header Cache-Control "public, max-age=3600" always;
|
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 {
|
location = /sw.js {
|
||||||
default_type application/javascript;
|
default_type application/javascript;
|
||||||
expires off;
|
expires off;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MischLabs - Offline</title>
|
<title>MischLabs - Offline</title>
|
||||||
<meta name="theme-color" content="#0f172a">
|
<meta name="theme-color" content="#0f172a">
|
||||||
<link rel="stylesheet" href="/style.css?v=9">
|
<link rel="stylesheet" href="/style.css?v=10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell hero">
|
<main class="shell hero">
|
||||||
@@ -20,4 +20,3 @@
|
|||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
110
services.json
Normal file
110
services.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"category": "access",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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": "work",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
161
style.css
161
style.css
@@ -406,12 +406,173 @@ main {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions.compact {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 13px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(17, 22, 36, 0.86);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:hover,
|
||||||
|
.ghost-button:focus-visible {
|
||||||
|
border-color: rgba(103, 232, 249, 0.38);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(17, 22, 36, 0.82);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel h2 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-copy {
|
||||||
|
max-width: 760px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice,
|
||||||
|
.admin-user {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(7, 9, 20, 0.55);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user {
|
||||||
|
color: #c4f1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(170px, 1.1fr) minmax(150px, 0.7fr) minmax(220px, 1.2fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(7, 9, 20, 0.42);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row strong,
|
||||||
|
.admin-row span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row input,
|
||||||
|
.admin-row select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(17, 22, 36, 0.92);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostic {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row.is-online {
|
||||||
|
border-color: rgba(52, 211, 153, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row.is-offline {
|
||||||
|
border-color: rgba(251, 113, 133, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero {
|
.hero {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-head {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-meta {
|
.hero-meta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
7
sw.js
7
sw.js
@@ -1,10 +1,11 @@
|
|||||||
const CACHE_NAME = 'mischlabs-pwa-v3';
|
const CACHE_NAME = 'mischlabs-pwa-v4';
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/offline.html',
|
'/offline.html',
|
||||||
'/style.css?v=9',
|
'/style.css?v=10',
|
||||||
'/app.js?v=1',
|
'/app.js?v=2',
|
||||||
|
'/services.json?v=1',
|
||||||
'/manifest.webmanifest?v=3',
|
'/manifest.webmanifest?v=3',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
'/icons/icon-512.png',
|
'/icons/icon-512.png',
|
||||||
|
|||||||
Reference in New Issue
Block a user