diff --git a/Brain.md b/Brain.md index f301304..f25d519 100644 --- a/Brain.md +++ b/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 - **Dashboard**: Kategorien `Zugang`, `Medien`, `Arbeit`, `Persoenlich`, Suche und clientseitige no-cors Statuschecks - **Auth-Link**: Zeigt auf die Keycloak Account Console (`/realms/mischlabs/account`), nicht mehr auf die Admin-Konsole +- **Adminseite**: `/admin.html` nutzt Keycloak Authorization Code + PKCE fuer Login und bietet Editor/Statusdiagnose fuer Dienste ## Docker-Setup - **Container-Name**: `mischlabs` @@ -59,6 +60,9 @@ mischlabs/ index.html - Haupt-HTML mit allen 9 Service-Kacheln style.css - Komplettes Styling (Dark Theme, Grid, Animationen) app.js - Suche, Filter, Statuschecks, Service-Worker-Registrierung + admin.html - SSO-geschuetzte Adminoberflaeche + admin.js - Keycloak PKCE Login, Editor, Statusdiagnose + services.json - Zentrale Service-/Kategorie-Konfiguration offline.html - Offline-Fallback fuer installierte PWA manifest.webmanifest - PWA-Metadaten fuer installierbare App sw.js - Schlanker Service Worker mit App-Shell-Cache @@ -105,8 +109,8 @@ mischlabs/ - `icons/icon-512.png` (512x512) - `icons/maskable-512.png` (512x512, maskable) - Service Worker: - - Cache-Name: `mischlabs-pwa-v3` - - Cached die App-Shell (`/`, `index.html`, `offline.html`, `style.css?v=9`, `app.js?v=1`, Manifest und Icons) + - 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. @@ -121,3 +125,19 @@ mischlabs/ - 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. Fuer echte Fehlerdetails braucht es ebenfalls ein Backend. diff --git a/Dockerfile b/Dockerfile index a7d4057..0c9f555 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,12 @@ FROM nginx:alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY index.html /usr/share/nginx/html/ +COPY admin.html /usr/share/nginx/html/ COPY offline.html /usr/share/nginx/html/ COPY style.css /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 sw.js /usr/share/nginx/html/ COPY icons/ /usr/share/nginx/html/icons/ diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..bb65310 --- /dev/null +++ b/admin.html @@ -0,0 +1,62 @@ + + + + + + MischLabs Admin + + + + + +
+
+ +
+

Admin

+

MischLabs

+

Dienste pruefen, Kategorien vorbereiten und Dashboard-Konfiguration verwalten.

+
+
+ +
+ Dashboard + + +
+
+ +
+
+

SSO

+

Anmeldung erforderlich

+

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.

+
Noch nicht angemeldet.
+
+ + +
+ + + + diff --git a/admin.js b/admin.js new file mode 100644 index 0000000..7f6f5c5 --- /dev/null +++ b/admin.js @@ -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) => ( + `` + )).join(''); +} + +function renderEditor() { + serviceEditor.innerHTML = config.services.map((service, index) => ` +
+
+ ${service.name} + ${service.domain} +
+ + + +
Noch nicht geprueft.
+
+ `).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}`; +}); + diff --git a/app.js b/app.js index e109a2c..b3d6422 100644 --- a/app.js +++ b/app.js @@ -1,15 +1,114 @@ -const cards = [...document.querySelectorAll('.service-card')]; -const sections = [...document.querySelectorAll('.section')]; +const CONFIG_URL = '/services.json?v=1'; +const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config'; + +const icons = { + shield: '', + folder: '', + lock: '', + film: '', + headphones: '', + book: '', + git: '', + chess: '', + user: '' +}; + +let cards = []; +let sections = []; +let config = null; +let activeFilter = 'all'; + const searchInput = document.querySelector('#serviceSearch'); const segments = [...document.querySelectorAll('.segment')]; const emptyState = document.querySelector('#emptyState'); const onlineCount = document.querySelector('#onlineCount'); +const serviceCount = document.querySelector('#serviceCount'); const lastChecked = document.querySelector('#lastChecked'); - -let activeFilter = 'all'; +const dashboardSections = document.querySelector('#dashboardSections'); 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 = ` + +
+ +
+
+

${service.name}

+

${service.description}

+
+ ${service.domain} + `; + 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 = ` +
+
+

${category.label}

+

${category.title}

+
+ ${services.length} ${services.length === 1 ? 'Dienst' : 'Dienste'} +
+
+ `; + + 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() { @@ -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) => { segment.addEventListener('click', () => setActiveFilter(segment.dataset.filter)); }); @@ -89,18 +201,14 @@ if ('serviceWorker' in navigator) { }); } -window.addEventListener('load', () => { - const run = () => refreshStatus().catch(() => { +loadConfig() + .then((nextConfig) => { + config = nextConfig; + renderDashboard(config); + updateVisibility(); + scheduleStatusRefresh(); + }) + .catch(() => { + dashboardSections.innerHTML = '

Dashboard-Konfiguration konnte nicht geladen werden.

'; onlineCount.textContent = '--'; - lastChecked.textContent = 'Status aktuell nicht verfuegbar.'; }); - - if ('requestIdleCallback' in window) { - window.requestIdleCallback(run, { timeout: 2000 }); - } else { - window.setTimeout(run, 600); - } -}); - -updateVisibility(); - diff --git a/index.html b/index.html index 92e6ee6..b4c10dd 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - +
@@ -32,7 +32,7 @@ Online
- 9 + 9 Dienste
@@ -63,185 +63,7 @@
-
-
-
-

Zugang

-

Accounts & Sicherheit

-
- 3 Dienste -
- - -
- -
-
-
-

Medien

-

Bibliothek & Streaming

-
- 3 Dienste -
- - -
- -
-
-
-

Arbeit

-

Code, Projekte & Spiel

-
- 3 Dienste -
- - -
+
@@ -251,6 +73,6 @@ Status wird im Hintergrund geprueft. - + diff --git a/nginx.conf b/nginx.conf index 2af7f0b..9ef33e3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -14,6 +14,11 @@ server { 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; diff --git a/offline.html b/offline.html index ceb4897..71ecb21 100644 --- a/offline.html +++ b/offline.html @@ -5,7 +5,7 @@ MischLabs - Offline - +
@@ -20,4 +20,3 @@
- diff --git a/services.json b/services.json new file mode 100644 index 0000000..80824d9 --- /dev/null +++ b/services.json @@ -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" + } + ] +} + diff --git a/style.css b/style.css index 65d6a66..1eace67 100644 --- a/style.css +++ b/style.css @@ -406,12 +406,173 @@ main { 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) { .hero { grid-template-columns: 1fr; align-items: start; } + .admin-actions { + justify-content: flex-start; + } + + .admin-panel-head { + display: grid; + } + + .admin-row { + grid-template-columns: 1fr; + } + .hero-meta { width: 100%; } diff --git a/sw.js b/sw.js index 5c7e827..b27050a 100644 --- a/sw.js +++ b/sw.js @@ -1,10 +1,11 @@ -const CACHE_NAME = 'mischlabs-pwa-v3'; +const CACHE_NAME = 'mischlabs-pwa-v4'; const APP_SHELL = [ '/', '/index.html', '/offline.html', - '/style.css?v=9', - '/app.js?v=1', + '/style.css?v=10', + '/app.js?v=2', + '/services.json?v=1', '/manifest.webmanifest?v=3', '/icons/icon-192.png', '/icons/icon-512.png',