From 9069b512d735223176f49dba4c49f306d7a93b86 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 18:26:25 +0200 Subject: [PATCH 01/12] feat(ui): surface services in responsive cards --- css/styles.css | 267 ++++++++++++++++------------------------ index.html | 322 +++++++++++++++++++++++-------------------------- 2 files changed, 251 insertions(+), 338 deletions(-) diff --git a/css/styles.css b/css/styles.css index 4689ca5..756a817 100644 --- a/css/styles.css +++ b/css/styles.css @@ -107,21 +107,11 @@ h1 { } .pc-status { - margin-top: 0; - display: inline-flex; + display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; - white-space: nowrap; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--line); - background: linear-gradient(180deg, #111b26, #0b111a); - font-weight: 600; - font-size: .92rem; -} - -.pc-status .pc-main { - font-weight: 700; + margin-top: 14px; } .pc-status .pc-os { @@ -136,73 +126,6 @@ h1 { line-height: 1.2; } -.pc-status.is-on .pc-main { - color: var(--good); -} - -.pc-status.is-off .pc-main { - color: var(--bad); -} - -nav.tabs { - display: flex; - gap: 8px; - flex-wrap: nowrap; - overflow: visible; - /* no scroll gutter, no clipping */ - scrollbar-width: none; - /* FF: hide scrollbar */ - flex: 0 1 auto; - /* don't steal width from brand */ -} - -nav.tabs::-webkit-scrollbar { - display: none; -} - -nav.tabs::-webkit-scrollbar { - display: none; -} - -.tab { - padding: 8px 16px; - border: 1px solid var(--line); - border-radius: 9999px; - background: var(--card); - color: var(--fg); - font-weight: 600; - cursor: pointer; - white-space: nowrap; - transition: transform .2s ease, background .2s ease; - position: relative; - /* for ::after ring */ - isolation: isolate; - /* create its own stacking context so ring isn't hidden */ -} - -.tab:hover { - transform: translateY(-2px); - background: linear-gradient(180deg, #111b26, #0b111a) -} - -/* Nice, always-visible ring for active tab */ -.tab[aria-selected="true"] { - outline: none -} - -.tab[aria-selected="true"]::after { - content: ""; - position: absolute; - top: -4px; - right: -4px; - bottom: -4px; - left: -4px; - /* extends evenly around */ - border-radius: 9999px; - border: 2px solid var(--acc); - pointer-events: none; -} - .controls { display: flex; gap: 8px; @@ -225,7 +148,6 @@ nav.tabs::-webkit-scrollbar { outline: 2px solid var(--acc) } -.tab:focus-visible, .chip:focus-visible, a:focus-visible { outline: 2px solid var(--acc); @@ -291,18 +213,11 @@ a:hover { box-shadow: var(--ring) } -.j-wrap { - display: flex; - gap: 18px; - align-items: center; - justify-content: space-between; - flex-wrap: wrap -} - .j-left { display: flex; gap: 16px; - align-items: center + align-items: flex-start; + min-width: 0; } .j-icon { @@ -318,6 +233,72 @@ a:hover { height: 70px; } +.service-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 18px; +} + +.section-heading { + grid-column: 1 / -1; +} + +.section-heading h2 { + margin: 0; +} + +.service-card { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: space-between; + gap: 18px; + min-height: 218px; + margin-bottom: 0; +} + +.service-card-copy { + min-width: 0; +} + +.service-card h2 { + margin: 0 0 6px; +} + +.service-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.service-status .dot { + background: var(--mut); +} + +.service-status.status-online .dot { + background: var(--good); +} + +.service-status.status-unreachable .dot { + background: var(--bad); +} + +.service-status.status-unknown .dot { + background: var(--mut); +} + +.service-latency { + color: var(--mut); +} + +.service-note { + margin: 10px 0 0; + color: var(--mut); + font-size: .85rem; +} + .pill { display: inline-flex; align-items: center; @@ -336,14 +317,19 @@ a:hover { } .status-history { - margin-top: 16px; + margin-bottom: 18px; } -.status-history h3 { - margin: 0 0 10px; - color: var(--mut); - font-size: .95rem; +.status-history > summary, +.client-info > summary { + cursor: pointer; font-weight: 700; + list-style-position: outside; +} + +.status-history[open] > summary, +.client-info[open] > summary { + margin-bottom: 14px; } .history-grid { @@ -387,6 +373,10 @@ a:hover { color: var(--good); } +.history-summary.is-collecting { + color: var(--mut); +} + .history-summary.is-recovering { color: #f59e0b; } @@ -476,10 +466,9 @@ footer { color: #0b1220 } -.theme-light .tab, .theme-light .chip, .theme-light .pill, -.theme-light .pc-status { +.theme-light .pc-status .pc-os { background: linear-gradient(180deg, #ffffff, #eef3f9); border-color: #d9e1ec; color: #0b1220 @@ -489,11 +478,9 @@ footer { background: radial-gradient(1400px 800px at 10% -10%, #ffffff 0, #f2f5fa 60%) fixed } -.theme-light .tab, .theme-light .chip, .theme-light .btn, -.theme-light .pill, -.theme-light .pc-status { +.theme-light .pill { background: linear-gradient(180deg, #f9fafb, #f3f4f6); color: var(--fg) } @@ -502,7 +489,8 @@ footer { background: var(--card) } -.theme-light .history-row { +.theme-light .history-row, +.theme-light .client-info { background: #f9fafb; } @@ -510,28 +498,6 @@ footer { color: #d97706; } -.toast { - position: fixed; - left: 50%; - bottom: 20px; - transform: translate(-50%, 10px); - background: var(--card); - color: var(--fg); - border: 1px solid var(--line); - border-radius: 12px; - padding: 10px 14px; - box-shadow: var(--shadow); - opacity: 0; - pointer-events: none; - transition: opacity .2s ease, transform .2s ease; - z-index: 50; -} - -.toast.show { - opacity: 1; - transform: translate(-50%, 0) -} - .theme-light .topbar { background: rgba(255, 255, 255, 0.86); border-bottom: 1px solid var(--line); @@ -567,17 +533,13 @@ footer { .theme-oled .card, .theme-oled .btn, -.theme-oled .tab, .theme-oled .chip, -.theme-oled .pill, -.theme-oled .pc-status, -.theme-oled .toast { +.theme-oled .pill { background: #000; border-color: var(--line); color: var(--fg); } -.theme-oled .tab:hover, .theme-oled .btn:hover, .theme-oled .chip:hover { background: #050505; @@ -656,12 +618,6 @@ html.theme-light #theme-toggle { left: 0; } -.card.active-card { - outline: 3px solid var(--acc); - outline-offset: 4px; - border-radius: 1rem -} - .github-link { display: inline-flex; align-items: center; @@ -675,21 +631,17 @@ html.theme-light #theme-toggle { text-decoration: underline; } +.campaign-link { + font-size: .85rem; +} + @media (max-width: 900px) { .controls { flex: 1 1 100%; width: 100%; } - .pc-status { - width: 100%; - white-space: normal; - flex-wrap: wrap; - row-gap: 6px; - } - - .pc-status .pc-os, - .pc-status .pc-main { + .pc-status .pc-os { max-width: 100%; overflow-wrap: anywhere; } @@ -713,26 +665,11 @@ html.theme-light #theme-toggle { align-items: flex-start; } - nav.tabs { - flex: 1 1 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - padding: 4px; - } - - .tab[aria-selected="true"]::after { - top: 0; - right: 0; - bottom: 0; - left: 0; - } - .controls { gap: 10px; } - .chip, - .tab { + .chip { padding: 10px 12px; min-height: 44px; } @@ -741,12 +678,6 @@ html.theme-light #theme-toggle { padding: 16px; } - .j-wrap { - flex-direction: column; - align-items: stretch; - gap: 14px; - } - .j-left { align-items: flex-start; } @@ -757,7 +688,11 @@ html.theme-light #theme-toggle { height: 56px; } - .btn { + .service-grid { + grid-template-columns: 1fr; + } + + .service-card .btn { width: 100%; justify-content: center; min-height: 44px; diff --git a/index.html b/index.html index 531f991..ef26c16 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@ - +
@@ -31,19 +31,6 @@

adiker.eu

Usually available: 9:00 AM – 1:00 AM CEST
-
@@ -52,211 +39,200 @@

adiker.eu

-
My PC is off :(
-
-

📊 Service dashboard

-

Live status overview

+
+

📊 Service status

+

Live health checks from this browser

- 0/5 online - Last check: -- - Jellyfin: -- ms - FileBrowser: -- ms - autobrr: -- ms - OpenSpeedTest: -- ms - Muse: -- ms -
-
-

Recent checks

-
-
-
- Jellyfin - No data yet -
- - Avg: -- ms -
-
-
- FileBrowser - No data yet -
- - Avg: -- ms -
-
-
- autobrr - No data yet -
- - Avg: -- ms -
-
-
- OpenSpeedTest - No data yet -
- - Avg: -- ms -
-
-
- Muse - No data yet -
- - Avg: -- ms -
-
+ Checking… 0/5 online + Last update: --
-
-
+
+
+

Services

+
+
-
-

🎬 Jellyfin

-

Your media server

-
- Offline +
+

🎬 Jellyfin

+

Your media server

+
+ + Checking… + + Latency: -- ms
- -
-
- - - - + +
+ Recent measurements +
+
+
+ Jellyfin + No data yet +
+ + Avg: -- ms +
+
+
+ FileBrowser + No data yet +
+ + Avg: -- ms +
+
+
+ autobrr + No data yet +
+ + Avg: -- ms +
+
+
+ OpenSpeedTest + No data yet +
+ + Avg: -- ms +
+
+
+ Muse + No data yet +
+ + Avg: -- ms +
+
+
+ +
+ Client information +
+
-
- From 6a3d913e7ecde00f11f7ddd20707d33028c258f5 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 18:28:42 +0200 Subject: [PATCH 02/12] fix(status): clarify health check states --- js/app.js | 243 ++++++++++++++++++++++-------------------------------- 1 file changed, 98 insertions(+), 145 deletions(-) diff --git a/js/app.js b/js/app.js index 6c66e4a..1617f1b 100644 --- a/js/app.js +++ b/js/app.js @@ -3,6 +3,7 @@ const LANG_KEY = 'adiker.lang'; const STATUS_HISTORY_KEY = 'adiker.statusHistory.v1'; const THEME_ORDER = ['dark', 'light', 'oled']; const HISTORY_LIMIT = 24; +const MIN_STABLE_SAMPLES = 6; let __animTheme = false; let lastCheckAt = null; @@ -13,11 +14,11 @@ const DETECTED_BROWSER = detectClientBrowser(); const DETECTED_DEVICE = detectClientDevice(); const SERVICE_STATE = { - jf: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - fb: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - ab: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - st: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - mu: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } + jf: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + fb: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + ab: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + st: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + mu: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } }; const SERVICES = [ @@ -30,34 +31,33 @@ const SERVICES = [ const STR = { en: { - nav: { - jellyfin: 'Jellyfin', - filebrowser: 'Filebrowser', - autobrr: 'Autobrr', - openspeedtest: 'OpenSpeedTest', - muse: 'Muse', - more: 'More services (soon)' - }, + documentTitle: 'Homepage', subtitle: 'My Playground', availability: 'Usually available: 9:00 AM – 1:00 AM CEST', ui: { theme: 'Theme', themeTitle: 'Toggle theme', language: 'Language', - sections: 'Sections', + skipLink: 'Skip to main content', + clientInfo: 'Client information', + services: 'Services', + latency: 'Latency', dark: 'Dark', light: 'Light', oled: 'OLED' }, dashboard: { - title: '📊 Service dashboard', - sub: 'Live status overview', + title: '📊 Service status', + sub: 'Live health checks from this browser', overall: 'online', - lastCheck: 'Last check', + checking: 'Checking…', + unreachable: 'unreachable', + lastCheck: 'Last update', notYet: '--', - historyTitle: 'Recent checks', + historyTitle: 'Recent measurements', avgLatency: 'Avg', noData: 'No data yet', + collecting: 'Collecting data', stable: 'Stable', recovering: 'Recovering', issues: 'Issues detected' @@ -66,11 +66,10 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Your file manager', open: 'Open FileBrowser Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automated torrent management', open: 'Open autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Network speed test', open: 'Open OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Discord music bot', short: 'Muse' }, - status: { online: 'Online', offline: 'Offline' }, + mu: { title: 'Muse', sub: 'Discord music bot', note: 'Status only · no web interface', short: 'Muse' }, + status: { checking: 'Checking…', online: 'Online', unreachable: 'Unreachable', unknown: 'Unknown' }, + statusSource: 'Status measured from this browser', pc: { - on: 'My PC is on :)', - off: 'My PC is off :(', osPrefix: 'Your OS', browserPrefix: 'Your browser', devicePrefix: 'Your device', @@ -104,38 +103,36 @@ const STR = { other: 'Other' } }, - footer: { served: 'Served by GitHub/Caddy' }, - toastMore: 'Coming soon 🙂' + footer: { served: 'Served by GitHub/Caddy', campaign: 'Keep Android Open' } }, pl: { - nav: { - jellyfin: 'Jellyfin', - filebrowser: 'Filebrowser', - autobrr: 'Autobrr', - openspeedtest: 'OpenSpeedTest', - muse: 'Muse', - more: 'Więcej usług (wkrótce)' - }, + documentTitle: 'Strona główna', subtitle: 'Mój plac zabaw', availability: 'Zwykle dostępny: 09:00 – 01:00 CEST', ui: { theme: 'Motyw', themeTitle: 'Przełącz motyw', language: 'Język', - sections: 'Sekcje', + skipLink: 'Przejdź do treści głównej', + clientInfo: 'Informacje o kliencie', + services: 'Usługi', + latency: 'Opóźnienie', dark: 'Ciemny', light: 'Jasny', oled: 'OLED' }, dashboard: { - title: '📊 Panel usług', - sub: 'Podgląd statusu na żywo', + title: '📊 Status usług', + sub: 'Bieżące pomiary z tej przeglądarki', overall: 'online', - lastCheck: 'Ostatnie sprawdzenie', + checking: 'Sprawdzanie…', + unreachable: 'bez odpowiedzi', + lastCheck: 'Ostatnia aktualizacja', notYet: '--', - historyTitle: 'Ostatnie sprawdzenia', + historyTitle: 'Ostatnie pomiary', avgLatency: 'Śr.', noData: 'Brak danych', + collecting: 'Zbieranie danych', stable: 'Stabilnie', recovering: 'Wraca do normy', issues: 'Wykryto problemy' @@ -144,11 +141,10 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Twój menedżer plików', open: 'Otwórz FileBrowsera Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automatyzacja torrentów', open: 'Otwórz autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Test prędkości sieci', open: 'Otwórz OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', short: 'Muse' }, - status: { online: 'Online', offline: 'Offline' }, + mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', note: 'Tylko status · brak interfejsu WWW', short: 'Muse' }, + status: { checking: 'Sprawdzanie…', online: 'Online', unreachable: 'Brak odpowiedzi', unknown: 'Nieznany' }, + statusSource: 'Status mierzony z tej przeglądarki', pc: { - on: 'Mój PC jest włączony :)', - off: 'Mój PC jest wyłączony :(', osPrefix: 'Twój OS', browserPrefix: 'Twoja przeglądarka', devicePrefix: 'Twoje urządzenie', @@ -182,8 +178,7 @@ const STR = { other: 'Inne' } }, - footer: { served: 'Hostowane przez GitHub/Caddy' }, - toastMore: 'Wkrótce 🙂' + footer: { served: 'Hostowane przez GitHub/Caddy', campaign: 'Keep Android Open' } } }; @@ -277,14 +272,6 @@ function setText(id, value) { if (el) el.textContent = value; } -function showToast(msg) { - const t = document.getElementById('toast'); - if (!t) return; - t.textContent = msg; - t.classList.add('show'); - setTimeout(() => t.classList.remove('show'), 1600); -} - function getServiceLabel(key, lang = getLang()) { const L = STR[lang] || STR.en; if (L[key] && L[key].short) return L[key].short; @@ -380,6 +367,7 @@ function getHistorySummaryType(history) { const lastOnline = history[history.length - 1].online; if (failures >= 2) return 'issues'; + if (failures === 0 && history.length < MIN_STABLE_SAMPLES) return 'collecting'; if (failures === 0) return 'stable'; return lastOnline ? 'recovering' : 'issues'; } @@ -404,7 +392,7 @@ function renderHistoryTimeline(key, lang, L) { const summaryEl = document.getElementById(`history-${key}-summary`); if (summaryEl) { summaryEl.textContent = summary; - summaryEl.classList.remove('is-stable', 'is-recovering', 'is-issues'); + summaryEl.classList.remove('is-stable', 'is-recovering', 'is-issues', 'is-collecting'); if (summaryType !== 'noData') summaryEl.classList.add(`is-${summaryType}`); } setText(`history-${key}-avg`, `${L.dashboard.avgLatency}: ${avgLatency ?? '--'} ms`); @@ -434,7 +422,7 @@ function renderHistoryTimeline(key, lang, L) { } else { segment.classList.add(entry.online ? 'is-online' : 'is-offline'); const time = new Date(entry.at).toLocaleTimeString(locale, timeOpts); - const status = entry.online ? L.status.online : L.status.offline; + const status = entry.online ? L.status.online : L.status.unreachable; segment.title = entry.online && entry.latency != null ? `${status} — ${entry.latency} ms — ${time}` : `${status} — ${time}`; @@ -442,52 +430,55 @@ function renderHistoryTimeline(key, lang, L) { } const ariaText = history.length - ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${offlineCount} ${L.status.offline}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` + ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${offlineCount} ${L.status.unreachable}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` : `${label}: ${L.dashboard.noData}`; line.setAttribute('aria-label', ariaText); } function renderPcStatus(lang = getLang()) { const L = STR[lang] || STR.en; - const anyOnline = Object.values(SERVICE_STATE).some((s) => s.online); const pcStatus = document.getElementById('pc-status'); if (!pcStatus) return; const osLabel = L.pc.os[DETECTED_OS] || L.pc.os.other; const browserLabel = L.pc.browser[DETECTED_BROWSER] || L.pc.browser.other; const deviceLabel = L.pc.device[DETECTED_DEVICE] || L.pc.device.other; - const pcText = anyOnline ? L.pc.on : L.pc.off; pcStatus.innerHTML = ` - ${pcText} ${L.pc.osPrefix}: ${osLabel} ${L.pc.browserPrefix}: ${browserLabel} ${L.pc.devicePrefix}: ${deviceLabel} `; - - pcStatus.classList.toggle('is-on', anyOnline); - pcStatus.classList.toggle('is-off', !anyOnline); } function renderDashboard(lang = getLang()) { const L = STR[lang] || STR.en; const total = SERVICES.length; - const onlineCount = Object.values(SERVICE_STATE).filter((s) => s.online).length; + const states = Object.values(SERVICE_STATE); + const onlineCount = states.filter((s) => s.status === 'online').length; + const checkingCount = states.filter((s) => s.status === 'checking' || s.status === 'unknown').length; + const unreachableCount = states.filter((s) => s.status === 'unreachable').length; setText('dash-overall', `${onlineCount}/${total} ${L.dashboard.overall}`); setText('dash-title', L.dashboard.title); setText('dash-sub', L.dashboard.sub); + const overall = document.getElementById('dash-overall'); + if (overall) { + const detail = checkingCount + ? `${L.dashboard.checking} ${checkingCount}` + : (unreachableCount ? `${unreachableCount} ${L.dashboard.unreachable}` : ''); + overall.textContent = detail ? `${detail} · ${onlineCount}/${total} ${L.dashboard.overall}` : `${onlineCount}/${total} ${L.dashboard.overall}`; + overall.classList.toggle('is-checking', checkingCount > 0); + overall.classList.toggle('is-unreachable', checkingCount === 0 && unreachableCount > 0); + } + const lastText = lastCheckAt ? new Date(lastCheckAt).toLocaleTimeString(lang === 'pl' ? 'pl-PL' : 'en-GB') : L.dashboard.notYet; setText('dash-lastcheck', `${L.dashboard.lastCheck}: ${lastText}`); - - setText('dash-jf-latency', `${L.jf.short}: ${SERVICE_STATE.jf.latency ?? '--'} ms`); - setText('dash-fb-latency', `${L.fb.short}: ${SERVICE_STATE.fb.latency ?? '--'} ms`); - setText('dash-ab-latency', `${L.ab.short}: ${SERVICE_STATE.ab.latency ?? '--'} ms`); - setText('dash-st-latency', `${L.st.short}: ${SERVICE_STATE.st.latency ?? '--'} ms`); - setText('dash-mu-latency', `${L.mu.short}: ${SERVICE_STATE.mu.latency ?? '--'} ms`); setText('history-title', L.dashboard.historyTitle); + setText('client-title', L.ui.clientInfo); + setText('mu-note', L.mu.note); SERVICES.forEach((service) => renderHistoryTimeline(service.key, lang, L)); } @@ -496,6 +487,9 @@ function applyLang(lang) { const L = STR[lang] || STR.en; document.documentElement.lang = lang; + document.title = `adiker.eu — ${L.documentTitle}`; + setText('skip-link', L.ui.skipLink); + setText('services-title', L.ui.services); setText('jf-title', '🎬 ' + L.jf.title); setText('jf-sub', L.jf.sub); setText('jf-btn-text', L.jf.open); @@ -514,6 +508,7 @@ function applyLang(lang) { setText('subtitle', L.subtitle); setText('availability', L.availability); setText('served', L.footer.served); + setText('campaign-link', L.footer.campaign); const jfBtn = document.getElementById('jf-btn'); const fbBtn = document.getElementById('fb-btn'); @@ -524,16 +519,6 @@ function applyLang(lang) { if (abBtn) abBtn.setAttribute('aria-label', L.ab.open); if (stBtn) stBtn.setAttribute('aria-label', L.st.open); - setText('tab-jellyfin', L.nav.jellyfin); - setText('tab-filebrowser', L.nav.filebrowser); - setText('tab-autobrr', L.nav.autobrr); - setText('tab-speedtest', L.nav.openspeedtest); - setText('tab-muse', L.nav.muse); - setText('tab-more', L.nav.more); - - const tabsNav = document.querySelector('nav.tabs'); - if (tabsNav) tabsNav.setAttribute('aria-label', L.ui.sections); - const langGroup = document.querySelector('[role="group"]'); if (langGroup) langGroup.setAttribute('aria-label', L.ui.language); @@ -550,6 +535,7 @@ function applyLang(lang) { updateThemeUI(lang); renderPcStatus(lang); renderDashboard(lang); + SERVICES.forEach((service) => renderServiceCard(service.key, lang)); localStorage.setItem(LANG_KEY, lang); } @@ -588,22 +574,40 @@ function updateThemeUI(lang) { } } -function setServiceStatus(prefix, ok, latency) { - const L = STR[getLang()] || STR.en; - const dot = document.getElementById(prefix + '-dot'); - const txt = document.getElementById(prefix + '-text'); +function renderServiceCard(prefix, lang = getLang()) { + const L = STR[lang] || STR.en; + const state = SERVICE_STATE[prefix]; + if (!state) return; + const pill = document.getElementById(prefix + '-pill'); + const txt = document.getElementById(prefix + '-text'); + const latency = document.getElementById(prefix + '-latency'); + const label = getServiceLabel(prefix, lang); + const statusLabel = L.status[state.status] || L.status.unknown; + + if (pill) { + pill.classList.remove('status-checking', 'status-online', 'status-unreachable', 'status-unknown'); + pill.classList.add(`status-${state.status}`); + pill.setAttribute('aria-label', `${label}: ${statusLabel}. ${L.statusSource}`); + } + if (txt) txt.textContent = statusLabel; + if (latency) latency.textContent = `${L.ui.latency}: ${state.latency ?? '--'} ms`; +} - if (dot) dot.style.background = ok ? 'var(--good)' : 'var(--bad)'; - if (txt) txt.textContent = ok ? L.status.online : L.status.offline; - if (pill) pill.setAttribute('aria-label', ok ? L.status.online : L.status.offline); +function setServiceStatus(prefix, status, latency = null) { + const state = SERVICE_STATE[prefix]; + if (!state) return; + + state.status = status; + state.online = status === 'online'; + state.latency = status === 'online' && Number.isFinite(latency) ? latency : null; + renderServiceCard(prefix); - SERVICE_STATE[prefix].online = !!ok; - SERVICE_STATE[prefix].latency = Number.isFinite(latency) ? latency : null; - lastCheckAt = Date.now(); - addStatusHistoryEntry(prefix, ok, latency); + if (status === 'online' || status === 'unreachable') { + lastCheckAt = Date.now(); + addStatusHistoryEntry(prefix, status === 'online', state.latency); + } - renderPcStatus(); renderDashboard(); } @@ -616,6 +620,7 @@ function getSmoothedLatency(state, latency) { async function refreshService(service) { const state = SERVICE_STATE[service.key]; + if (state.status === 'unknown') setServiceStatus(service.key, 'checking'); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), 2500); const started = performance.now(); @@ -635,22 +640,22 @@ async function refreshService(service) { // Pierwszy pomiar zwykle zawiera koszt DNS/TLS i potrafi być zawyżony. // Traktujemy go jako warm-up i pokazujemy stabilniejszy wynik z kolejnego sprawdzenia. state.warmedUp = true; - setServiceStatus(service.key, true, null); + setServiceStatus(service.key, 'online', null); state.failCount = 0; state.nextDelay = 800; } else if (ok) { const smoothedLatency = getSmoothedLatency(state, latency); - setServiceStatus(service.key, true, smoothedLatency); + setServiceStatus(service.key, 'online', smoothedLatency); state.failCount = 0; state.nextDelay = 15000; } else { - setServiceStatus(service.key, false, null); + setServiceStatus(service.key, 'unreachable'); state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); } } catch (_e) { clearTimeout(timeout); - setServiceStatus(service.key, false, null); + setServiceStatus(service.key, 'unreachable'); state.latencySamples = []; state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); @@ -659,57 +664,6 @@ async function refreshService(service) { setTimeout(() => refreshService(service), state.nextDelay); } -function initTabs() { - const tabIds = ['tab-jellyfin', 'tab-filebrowser', 'tab-autobrr', 'tab-speedtest', 'tab-muse']; - const map = { - 'tab-jellyfin': 'section-jf', - 'tab-filebrowser': 'section-fb', - 'tab-autobrr': 'section-ab', - 'tab-speedtest': 'section-st', - 'tab-muse': 'section-muse' - }; - - const tabs = tabIds.map((id) => document.getElementById(id)).filter(Boolean); - const moreBtn = document.getElementById('tab-more'); - - function activate(tabId) { - tabs.forEach((tab) => { - const active = tab.id === tabId; - tab.setAttribute('aria-selected', String(active)); - tab.setAttribute('tabindex', active ? '0' : '-1'); - - const panel = document.getElementById(map[tab.id]); - if (panel) { - panel.hidden = !active; - panel.classList.toggle('active-card', active); - } - }); - } - - tabs.forEach((tab) => { - tab.addEventListener('click', () => activate(tab.id)); - tab.addEventListener('keydown', (e) => { - const index = tabs.indexOf(tab); - if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { - e.preventDefault(); - const delta = e.key === 'ArrowRight' ? 1 : -1; - const next = (index + delta + tabs.length) % tabs.length; - tabs[next].focus(); - activate(tabs[next].id); - } - }); - }); - - if (moreBtn) { - moreBtn.addEventListener('click', () => { - const L = STR[getLang()] || STR.en; - showToast(L.toastMore); - }); - } - - activate('tab-jellyfin'); -} - function init() { setText('year', new Date().getFullYear()); loadStatusHistory(); @@ -741,7 +695,6 @@ function init() { }); window.addEventListener('beforeunload', flushSaveHistory); - initTabs(); SERVICES.forEach((service) => refreshService(service)); } From e482fbd47b8ba59c7ea2dbf000a883d2c5649dbc Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 18:29:19 +0200 Subject: [PATCH 03/12] fix(ui): simplify service hub header --- README.md | 24 ++++++++++++------------ css/styles.css | 6 ++++++ index.html | 9 +++++++-- js/app.js | 7 ++++++- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f77b4ab..75b01a4 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,16 @@ server. ### Features -- Service tabs with a clean card-based layout -- Live service dashboard with online count, last check time, and latency labels -- Browser-local recent status history with mini timelines and average latency -- Health checks for all configured services +- Responsive service cards with direct links to every web service +- Compact live service summary with online count and last update time +- Collapsed browser-local history of the last 24 measurements with mini timelines +- Health checks with explicit checking, online, and unreachable states - Local Muse readiness probe with Discord gateway connectivity check - Polling with warm-up measurement and failure backoff - EN/PL language switcher - Dark / Light / OLED themes -- Client info badge with OS, browser, and device detection -- Keyboard-accessible tabs and visible focus states +- Collapsed client information with OS, browser, and device detection +- Keyboard-accessible controls, skip link, and visible focus states ### Project Structure @@ -210,16 +210,16 @@ albo dowolnym serwerze statycznym. ### Funkcje -- Zakładki usług z czytelnym układem kart -- Dashboard statusów z liczbą usług online, czasem ostatniego sprawdzenia i opóźnieniami -- Lokalna historia ostatnich statusów z mini-timeline i średnim opóźnieniem -- Health checki dla wszystkich skonfigurowanych usług +- Responsywne karty usług z bezpośrednimi linkami do każdej usługi WWW +- Kompaktowe podsumowanie na żywo z liczbą usług online i czasem aktualizacji +- Zwijana lokalna historia 24 ostatnich pomiarów z mini-timeline +- Health checki ze stanami: sprawdzanie, online i brak odpowiedzi - Lokalna sonda gotowości Muse ze sprawdzeniem połączenia z gateway Discorda - Odpytywanie z pomiarem rozgrzewkowym i backoffem przy błędach - Przełącznik języka EN/PL - Motywy Dark / Light / OLED -- Etykieta z wykrytym OS, przeglądarką i typem urządzenia -- Zakładki dostępne z klawiatury i czytelne stany focus +- Zwijane informacje o wykrytym OS, przeglądarce i typie urządzenia +- Dostępne z klawiatury kontrolki, skip link i czytelne stany focus ### Struktura projektu diff --git a/css/styles.css b/css/styles.css index 756a817..1d03f50 100644 --- a/css/styles.css +++ b/css/styles.css @@ -299,6 +299,12 @@ a:hover { font-size: .85rem; } +.service-source { + margin: 8px 0 0; + color: var(--mut); + font-size: .76rem; +} + .pill { display: inline-flex; align-items: center; diff --git a/index.html b/index.html index ef26c16..4f90357 100644 --- a/index.html +++ b/index.html @@ -72,6 +72,7 @@

🎬 Jellyfin

Latency: -- ms +

Measured from this browser

📁 FileBrowser Quantum Latency: -- ms +

Measured from this browser

⚡ autobrr Latency: -- ms +

Measured from this browser

🚀 OpenSpeedTest Latency: -- ms +

Measured from this browser

🎧 Muse Latency: -- ms +

Measured from this browser

Status only · no web interface

@@ -250,8 +255,8 @@

🎧 Muse

- - + + diff --git a/js/app.js b/js/app.js index 1617f1b..c5dcced 100644 --- a/js/app.js +++ b/js/app.js @@ -42,6 +42,7 @@ const STR = { clientInfo: 'Client information', services: 'Services', latency: 'Latency', + source: 'Measured from this browser', dark: 'Dark', light: 'Light', oled: 'OLED' @@ -117,6 +118,7 @@ const STR = { clientInfo: 'Informacje o kliencie', services: 'Usługi', latency: 'Opóźnienie', + source: 'Pomiar z tej przeglądarki', dark: 'Ciemny', light: 'Jasny', oled: 'OLED' @@ -535,7 +537,10 @@ function applyLang(lang) { updateThemeUI(lang); renderPcStatus(lang); renderDashboard(lang); - SERVICES.forEach((service) => renderServiceCard(service.key, lang)); + SERVICES.forEach((service) => { + setText(`${service.key}-source`, L.ui.source); + renderServiceCard(service.key, lang); + }); localStorage.setItem(LANG_KEY, lang); } From 9c7cfb9bc8a0a6d524c28dfff0bfb04d90134e78 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:29:13 +0200 Subject: [PATCH 04/12] chore(ci): temporarily allow PR Pages deployment --- .github/workflows/pages.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5716769..3855108 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,12 @@ on: branches: - main workflow_dispatch: + inputs: + deploy_preview: + description: Deploy the selected PR branch to the Pages site temporarily + required: false + type: boolean + default: false permissions: contents: read @@ -68,7 +74,10 @@ jobs: deploy: name: Deploy to GitHub Pages - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: >- + github.event_name != 'pull_request' && + (github.ref == 'refs/heads/main' || + (github.ref == 'refs/heads/feature/p0-service-hub-ui' && inputs.deploy_preview == true)) runs-on: ubuntu-latest needs: validate From 13f2208f11617a986d064deafb439ebdcacafcc0 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:31:19 +0200 Subject: [PATCH 05/12] Revert "chore(ci): temporarily allow PR Pages deployment" This reverts commit 9c7cfb9bc8a0a6d524c28dfff0bfb04d90134e78. --- .github/workflows/pages.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 3855108..5716769 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,12 +8,6 @@ on: branches: - main workflow_dispatch: - inputs: - deploy_preview: - description: Deploy the selected PR branch to the Pages site temporarily - required: false - type: boolean - default: false permissions: contents: read @@ -74,10 +68,7 @@ jobs: deploy: name: Deploy to GitHub Pages - if: >- - github.event_name != 'pull_request' && - (github.ref == 'refs/heads/main' || - (github.ref == 'refs/heads/feature/p0-service-hub-ui' && inputs.deploy_preview == true)) + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: validate From 54a4cdf0da6d1fc34ab1c8a3b3f5a73c43899310 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:43:14 +0200 Subject: [PATCH 06/12] Revert "fix(ui): simplify service hub header" This reverts commit e482fbd47b8ba59c7ea2dbf000a883d2c5649dbc. --- README.md | 24 ++++++++++++------------ css/styles.css | 6 ------ index.html | 9 ++------- js/app.js | 7 +------ 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 75b01a4..f77b4ab 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,16 @@ server. ### Features -- Responsive service cards with direct links to every web service -- Compact live service summary with online count and last update time -- Collapsed browser-local history of the last 24 measurements with mini timelines -- Health checks with explicit checking, online, and unreachable states +- Service tabs with a clean card-based layout +- Live service dashboard with online count, last check time, and latency labels +- Browser-local recent status history with mini timelines and average latency +- Health checks for all configured services - Local Muse readiness probe with Discord gateway connectivity check - Polling with warm-up measurement and failure backoff - EN/PL language switcher - Dark / Light / OLED themes -- Collapsed client information with OS, browser, and device detection -- Keyboard-accessible controls, skip link, and visible focus states +- Client info badge with OS, browser, and device detection +- Keyboard-accessible tabs and visible focus states ### Project Structure @@ -210,16 +210,16 @@ albo dowolnym serwerze statycznym. ### Funkcje -- Responsywne karty usług z bezpośrednimi linkami do każdej usługi WWW -- Kompaktowe podsumowanie na żywo z liczbą usług online i czasem aktualizacji -- Zwijana lokalna historia 24 ostatnich pomiarów z mini-timeline -- Health checki ze stanami: sprawdzanie, online i brak odpowiedzi +- Zakładki usług z czytelnym układem kart +- Dashboard statusów z liczbą usług online, czasem ostatniego sprawdzenia i opóźnieniami +- Lokalna historia ostatnich statusów z mini-timeline i średnim opóźnieniem +- Health checki dla wszystkich skonfigurowanych usług - Lokalna sonda gotowości Muse ze sprawdzeniem połączenia z gateway Discorda - Odpytywanie z pomiarem rozgrzewkowym i backoffem przy błędach - Przełącznik języka EN/PL - Motywy Dark / Light / OLED -- Zwijane informacje o wykrytym OS, przeglądarce i typie urządzenia -- Dostępne z klawiatury kontrolki, skip link i czytelne stany focus +- Etykieta z wykrytym OS, przeglądarką i typem urządzenia +- Zakładki dostępne z klawiatury i czytelne stany focus ### Struktura projektu diff --git a/css/styles.css b/css/styles.css index 1d03f50..756a817 100644 --- a/css/styles.css +++ b/css/styles.css @@ -299,12 +299,6 @@ a:hover { font-size: .85rem; } -.service-source { - margin: 8px 0 0; - color: var(--mut); - font-size: .76rem; -} - .pill { display: inline-flex; align-items: center; diff --git a/index.html b/index.html index 4f90357..ef26c16 100644 --- a/index.html +++ b/index.html @@ -72,7 +72,6 @@

🎬 Jellyfin

Latency: -- ms -

Measured from this browser

📁 FileBrowser Quantum Latency: -- ms -

Measured from this browser

⚡ autobrr Latency: -- ms -

Measured from this browser

🚀 OpenSpeedTest Latency: -- ms -

Measured from this browser

🎧 Muse Latency: -- ms -

Measured from this browser

Status only · no web interface

@@ -255,8 +250,8 @@

🎧 Muse

- - + + diff --git a/js/app.js b/js/app.js index c5dcced..1617f1b 100644 --- a/js/app.js +++ b/js/app.js @@ -42,7 +42,6 @@ const STR = { clientInfo: 'Client information', services: 'Services', latency: 'Latency', - source: 'Measured from this browser', dark: 'Dark', light: 'Light', oled: 'OLED' @@ -118,7 +117,6 @@ const STR = { clientInfo: 'Informacje o kliencie', services: 'Usługi', latency: 'Opóźnienie', - source: 'Pomiar z tej przeglądarki', dark: 'Ciemny', light: 'Jasny', oled: 'OLED' @@ -537,10 +535,7 @@ function applyLang(lang) { updateThemeUI(lang); renderPcStatus(lang); renderDashboard(lang); - SERVICES.forEach((service) => { - setText(`${service.key}-source`, L.ui.source); - renderServiceCard(service.key, lang); - }); + SERVICES.forEach((service) => renderServiceCard(service.key, lang)); localStorage.setItem(LANG_KEY, lang); } From 65db1ed572413053a5c1f29bb32760cf90f0c134 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:43:14 +0200 Subject: [PATCH 07/12] Revert "fix(status): clarify health check states" This reverts commit 6a3d913e7ecde00f11f7ddd20707d33028c258f5. --- js/app.js | 243 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 145 insertions(+), 98 deletions(-) diff --git a/js/app.js b/js/app.js index 1617f1b..6c66e4a 100644 --- a/js/app.js +++ b/js/app.js @@ -3,7 +3,6 @@ const LANG_KEY = 'adiker.lang'; const STATUS_HISTORY_KEY = 'adiker.statusHistory.v1'; const THEME_ORDER = ['dark', 'light', 'oled']; const HISTORY_LIMIT = 24; -const MIN_STABLE_SAMPLES = 6; let __animTheme = false; let lastCheckAt = null; @@ -14,11 +13,11 @@ const DETECTED_BROWSER = detectClientBrowser(); const DETECTED_DEVICE = detectClientDevice(); const SERVICE_STATE = { - jf: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - fb: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - ab: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - st: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - mu: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } + jf: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + fb: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + ab: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + st: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + mu: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } }; const SERVICES = [ @@ -31,33 +30,34 @@ const SERVICES = [ const STR = { en: { - documentTitle: 'Homepage', + nav: { + jellyfin: 'Jellyfin', + filebrowser: 'Filebrowser', + autobrr: 'Autobrr', + openspeedtest: 'OpenSpeedTest', + muse: 'Muse', + more: 'More services (soon)' + }, subtitle: 'My Playground', availability: 'Usually available: 9:00 AM – 1:00 AM CEST', ui: { theme: 'Theme', themeTitle: 'Toggle theme', language: 'Language', - skipLink: 'Skip to main content', - clientInfo: 'Client information', - services: 'Services', - latency: 'Latency', + sections: 'Sections', dark: 'Dark', light: 'Light', oled: 'OLED' }, dashboard: { - title: '📊 Service status', - sub: 'Live health checks from this browser', + title: '📊 Service dashboard', + sub: 'Live status overview', overall: 'online', - checking: 'Checking…', - unreachable: 'unreachable', - lastCheck: 'Last update', + lastCheck: 'Last check', notYet: '--', - historyTitle: 'Recent measurements', + historyTitle: 'Recent checks', avgLatency: 'Avg', noData: 'No data yet', - collecting: 'Collecting data', stable: 'Stable', recovering: 'Recovering', issues: 'Issues detected' @@ -66,10 +66,11 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Your file manager', open: 'Open FileBrowser Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automated torrent management', open: 'Open autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Network speed test', open: 'Open OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Discord music bot', note: 'Status only · no web interface', short: 'Muse' }, - status: { checking: 'Checking…', online: 'Online', unreachable: 'Unreachable', unknown: 'Unknown' }, - statusSource: 'Status measured from this browser', + mu: { title: 'Muse', sub: 'Discord music bot', short: 'Muse' }, + status: { online: 'Online', offline: 'Offline' }, pc: { + on: 'My PC is on :)', + off: 'My PC is off :(', osPrefix: 'Your OS', browserPrefix: 'Your browser', devicePrefix: 'Your device', @@ -103,36 +104,38 @@ const STR = { other: 'Other' } }, - footer: { served: 'Served by GitHub/Caddy', campaign: 'Keep Android Open' } + footer: { served: 'Served by GitHub/Caddy' }, + toastMore: 'Coming soon 🙂' }, pl: { - documentTitle: 'Strona główna', + nav: { + jellyfin: 'Jellyfin', + filebrowser: 'Filebrowser', + autobrr: 'Autobrr', + openspeedtest: 'OpenSpeedTest', + muse: 'Muse', + more: 'Więcej usług (wkrótce)' + }, subtitle: 'Mój plac zabaw', availability: 'Zwykle dostępny: 09:00 – 01:00 CEST', ui: { theme: 'Motyw', themeTitle: 'Przełącz motyw', language: 'Język', - skipLink: 'Przejdź do treści głównej', - clientInfo: 'Informacje o kliencie', - services: 'Usługi', - latency: 'Opóźnienie', + sections: 'Sekcje', dark: 'Ciemny', light: 'Jasny', oled: 'OLED' }, dashboard: { - title: '📊 Status usług', - sub: 'Bieżące pomiary z tej przeglądarki', + title: '📊 Panel usług', + sub: 'Podgląd statusu na żywo', overall: 'online', - checking: 'Sprawdzanie…', - unreachable: 'bez odpowiedzi', - lastCheck: 'Ostatnia aktualizacja', + lastCheck: 'Ostatnie sprawdzenie', notYet: '--', - historyTitle: 'Ostatnie pomiary', + historyTitle: 'Ostatnie sprawdzenia', avgLatency: 'Śr.', noData: 'Brak danych', - collecting: 'Zbieranie danych', stable: 'Stabilnie', recovering: 'Wraca do normy', issues: 'Wykryto problemy' @@ -141,10 +144,11 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Twój menedżer plików', open: 'Otwórz FileBrowsera Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automatyzacja torrentów', open: 'Otwórz autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Test prędkości sieci', open: 'Otwórz OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', note: 'Tylko status · brak interfejsu WWW', short: 'Muse' }, - status: { checking: 'Sprawdzanie…', online: 'Online', unreachable: 'Brak odpowiedzi', unknown: 'Nieznany' }, - statusSource: 'Status mierzony z tej przeglądarki', + mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', short: 'Muse' }, + status: { online: 'Online', offline: 'Offline' }, pc: { + on: 'Mój PC jest włączony :)', + off: 'Mój PC jest wyłączony :(', osPrefix: 'Twój OS', browserPrefix: 'Twoja przeglądarka', devicePrefix: 'Twoje urządzenie', @@ -178,7 +182,8 @@ const STR = { other: 'Inne' } }, - footer: { served: 'Hostowane przez GitHub/Caddy', campaign: 'Keep Android Open' } + footer: { served: 'Hostowane przez GitHub/Caddy' }, + toastMore: 'Wkrótce 🙂' } }; @@ -272,6 +277,14 @@ function setText(id, value) { if (el) el.textContent = value; } +function showToast(msg) { + const t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.classList.add('show'); + setTimeout(() => t.classList.remove('show'), 1600); +} + function getServiceLabel(key, lang = getLang()) { const L = STR[lang] || STR.en; if (L[key] && L[key].short) return L[key].short; @@ -367,7 +380,6 @@ function getHistorySummaryType(history) { const lastOnline = history[history.length - 1].online; if (failures >= 2) return 'issues'; - if (failures === 0 && history.length < MIN_STABLE_SAMPLES) return 'collecting'; if (failures === 0) return 'stable'; return lastOnline ? 'recovering' : 'issues'; } @@ -392,7 +404,7 @@ function renderHistoryTimeline(key, lang, L) { const summaryEl = document.getElementById(`history-${key}-summary`); if (summaryEl) { summaryEl.textContent = summary; - summaryEl.classList.remove('is-stable', 'is-recovering', 'is-issues', 'is-collecting'); + summaryEl.classList.remove('is-stable', 'is-recovering', 'is-issues'); if (summaryType !== 'noData') summaryEl.classList.add(`is-${summaryType}`); } setText(`history-${key}-avg`, `${L.dashboard.avgLatency}: ${avgLatency ?? '--'} ms`); @@ -422,7 +434,7 @@ function renderHistoryTimeline(key, lang, L) { } else { segment.classList.add(entry.online ? 'is-online' : 'is-offline'); const time = new Date(entry.at).toLocaleTimeString(locale, timeOpts); - const status = entry.online ? L.status.online : L.status.unreachable; + const status = entry.online ? L.status.online : L.status.offline; segment.title = entry.online && entry.latency != null ? `${status} — ${entry.latency} ms — ${time}` : `${status} — ${time}`; @@ -430,55 +442,52 @@ function renderHistoryTimeline(key, lang, L) { } const ariaText = history.length - ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${offlineCount} ${L.status.unreachable}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` + ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${offlineCount} ${L.status.offline}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` : `${label}: ${L.dashboard.noData}`; line.setAttribute('aria-label', ariaText); } function renderPcStatus(lang = getLang()) { const L = STR[lang] || STR.en; + const anyOnline = Object.values(SERVICE_STATE).some((s) => s.online); const pcStatus = document.getElementById('pc-status'); if (!pcStatus) return; const osLabel = L.pc.os[DETECTED_OS] || L.pc.os.other; const browserLabel = L.pc.browser[DETECTED_BROWSER] || L.pc.browser.other; const deviceLabel = L.pc.device[DETECTED_DEVICE] || L.pc.device.other; + const pcText = anyOnline ? L.pc.on : L.pc.off; pcStatus.innerHTML = ` + ${pcText} ${L.pc.osPrefix}: ${osLabel} ${L.pc.browserPrefix}: ${browserLabel} ${L.pc.devicePrefix}: ${deviceLabel} `; + + pcStatus.classList.toggle('is-on', anyOnline); + pcStatus.classList.toggle('is-off', !anyOnline); } function renderDashboard(lang = getLang()) { const L = STR[lang] || STR.en; const total = SERVICES.length; - const states = Object.values(SERVICE_STATE); - const onlineCount = states.filter((s) => s.status === 'online').length; - const checkingCount = states.filter((s) => s.status === 'checking' || s.status === 'unknown').length; - const unreachableCount = states.filter((s) => s.status === 'unreachable').length; + const onlineCount = Object.values(SERVICE_STATE).filter((s) => s.online).length; setText('dash-overall', `${onlineCount}/${total} ${L.dashboard.overall}`); setText('dash-title', L.dashboard.title); setText('dash-sub', L.dashboard.sub); - const overall = document.getElementById('dash-overall'); - if (overall) { - const detail = checkingCount - ? `${L.dashboard.checking} ${checkingCount}` - : (unreachableCount ? `${unreachableCount} ${L.dashboard.unreachable}` : ''); - overall.textContent = detail ? `${detail} · ${onlineCount}/${total} ${L.dashboard.overall}` : `${onlineCount}/${total} ${L.dashboard.overall}`; - overall.classList.toggle('is-checking', checkingCount > 0); - overall.classList.toggle('is-unreachable', checkingCount === 0 && unreachableCount > 0); - } - const lastText = lastCheckAt ? new Date(lastCheckAt).toLocaleTimeString(lang === 'pl' ? 'pl-PL' : 'en-GB') : L.dashboard.notYet; setText('dash-lastcheck', `${L.dashboard.lastCheck}: ${lastText}`); + + setText('dash-jf-latency', `${L.jf.short}: ${SERVICE_STATE.jf.latency ?? '--'} ms`); + setText('dash-fb-latency', `${L.fb.short}: ${SERVICE_STATE.fb.latency ?? '--'} ms`); + setText('dash-ab-latency', `${L.ab.short}: ${SERVICE_STATE.ab.latency ?? '--'} ms`); + setText('dash-st-latency', `${L.st.short}: ${SERVICE_STATE.st.latency ?? '--'} ms`); + setText('dash-mu-latency', `${L.mu.short}: ${SERVICE_STATE.mu.latency ?? '--'} ms`); setText('history-title', L.dashboard.historyTitle); - setText('client-title', L.ui.clientInfo); - setText('mu-note', L.mu.note); SERVICES.forEach((service) => renderHistoryTimeline(service.key, lang, L)); } @@ -487,9 +496,6 @@ function applyLang(lang) { const L = STR[lang] || STR.en; document.documentElement.lang = lang; - document.title = `adiker.eu — ${L.documentTitle}`; - setText('skip-link', L.ui.skipLink); - setText('services-title', L.ui.services); setText('jf-title', '🎬 ' + L.jf.title); setText('jf-sub', L.jf.sub); setText('jf-btn-text', L.jf.open); @@ -508,7 +514,6 @@ function applyLang(lang) { setText('subtitle', L.subtitle); setText('availability', L.availability); setText('served', L.footer.served); - setText('campaign-link', L.footer.campaign); const jfBtn = document.getElementById('jf-btn'); const fbBtn = document.getElementById('fb-btn'); @@ -519,6 +524,16 @@ function applyLang(lang) { if (abBtn) abBtn.setAttribute('aria-label', L.ab.open); if (stBtn) stBtn.setAttribute('aria-label', L.st.open); + setText('tab-jellyfin', L.nav.jellyfin); + setText('tab-filebrowser', L.nav.filebrowser); + setText('tab-autobrr', L.nav.autobrr); + setText('tab-speedtest', L.nav.openspeedtest); + setText('tab-muse', L.nav.muse); + setText('tab-more', L.nav.more); + + const tabsNav = document.querySelector('nav.tabs'); + if (tabsNav) tabsNav.setAttribute('aria-label', L.ui.sections); + const langGroup = document.querySelector('[role="group"]'); if (langGroup) langGroup.setAttribute('aria-label', L.ui.language); @@ -535,7 +550,6 @@ function applyLang(lang) { updateThemeUI(lang); renderPcStatus(lang); renderDashboard(lang); - SERVICES.forEach((service) => renderServiceCard(service.key, lang)); localStorage.setItem(LANG_KEY, lang); } @@ -574,40 +588,22 @@ function updateThemeUI(lang) { } } -function renderServiceCard(prefix, lang = getLang()) { - const L = STR[lang] || STR.en; - const state = SERVICE_STATE[prefix]; - if (!state) return; - - const pill = document.getElementById(prefix + '-pill'); +function setServiceStatus(prefix, ok, latency) { + const L = STR[getLang()] || STR.en; + const dot = document.getElementById(prefix + '-dot'); const txt = document.getElementById(prefix + '-text'); - const latency = document.getElementById(prefix + '-latency'); - const label = getServiceLabel(prefix, lang); - const statusLabel = L.status[state.status] || L.status.unknown; - - if (pill) { - pill.classList.remove('status-checking', 'status-online', 'status-unreachable', 'status-unknown'); - pill.classList.add(`status-${state.status}`); - pill.setAttribute('aria-label', `${label}: ${statusLabel}. ${L.statusSource}`); - } - if (txt) txt.textContent = statusLabel; - if (latency) latency.textContent = `${L.ui.latency}: ${state.latency ?? '--'} ms`; -} - -function setServiceStatus(prefix, status, latency = null) { - const state = SERVICE_STATE[prefix]; - if (!state) return; + const pill = document.getElementById(prefix + '-pill'); - state.status = status; - state.online = status === 'online'; - state.latency = status === 'online' && Number.isFinite(latency) ? latency : null; - renderServiceCard(prefix); + if (dot) dot.style.background = ok ? 'var(--good)' : 'var(--bad)'; + if (txt) txt.textContent = ok ? L.status.online : L.status.offline; + if (pill) pill.setAttribute('aria-label', ok ? L.status.online : L.status.offline); - if (status === 'online' || status === 'unreachable') { - lastCheckAt = Date.now(); - addStatusHistoryEntry(prefix, status === 'online', state.latency); - } + SERVICE_STATE[prefix].online = !!ok; + SERVICE_STATE[prefix].latency = Number.isFinite(latency) ? latency : null; + lastCheckAt = Date.now(); + addStatusHistoryEntry(prefix, ok, latency); + renderPcStatus(); renderDashboard(); } @@ -620,7 +616,6 @@ function getSmoothedLatency(state, latency) { async function refreshService(service) { const state = SERVICE_STATE[service.key]; - if (state.status === 'unknown') setServiceStatus(service.key, 'checking'); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), 2500); const started = performance.now(); @@ -640,22 +635,22 @@ async function refreshService(service) { // Pierwszy pomiar zwykle zawiera koszt DNS/TLS i potrafi być zawyżony. // Traktujemy go jako warm-up i pokazujemy stabilniejszy wynik z kolejnego sprawdzenia. state.warmedUp = true; - setServiceStatus(service.key, 'online', null); + setServiceStatus(service.key, true, null); state.failCount = 0; state.nextDelay = 800; } else if (ok) { const smoothedLatency = getSmoothedLatency(state, latency); - setServiceStatus(service.key, 'online', smoothedLatency); + setServiceStatus(service.key, true, smoothedLatency); state.failCount = 0; state.nextDelay = 15000; } else { - setServiceStatus(service.key, 'unreachable'); + setServiceStatus(service.key, false, null); state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); } } catch (_e) { clearTimeout(timeout); - setServiceStatus(service.key, 'unreachable'); + setServiceStatus(service.key, false, null); state.latencySamples = []; state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); @@ -664,6 +659,57 @@ async function refreshService(service) { setTimeout(() => refreshService(service), state.nextDelay); } +function initTabs() { + const tabIds = ['tab-jellyfin', 'tab-filebrowser', 'tab-autobrr', 'tab-speedtest', 'tab-muse']; + const map = { + 'tab-jellyfin': 'section-jf', + 'tab-filebrowser': 'section-fb', + 'tab-autobrr': 'section-ab', + 'tab-speedtest': 'section-st', + 'tab-muse': 'section-muse' + }; + + const tabs = tabIds.map((id) => document.getElementById(id)).filter(Boolean); + const moreBtn = document.getElementById('tab-more'); + + function activate(tabId) { + tabs.forEach((tab) => { + const active = tab.id === tabId; + tab.setAttribute('aria-selected', String(active)); + tab.setAttribute('tabindex', active ? '0' : '-1'); + + const panel = document.getElementById(map[tab.id]); + if (panel) { + panel.hidden = !active; + panel.classList.toggle('active-card', active); + } + }); + } + + tabs.forEach((tab) => { + tab.addEventListener('click', () => activate(tab.id)); + tab.addEventListener('keydown', (e) => { + const index = tabs.indexOf(tab); + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + e.preventDefault(); + const delta = e.key === 'ArrowRight' ? 1 : -1; + const next = (index + delta + tabs.length) % tabs.length; + tabs[next].focus(); + activate(tabs[next].id); + } + }); + }); + + if (moreBtn) { + moreBtn.addEventListener('click', () => { + const L = STR[getLang()] || STR.en; + showToast(L.toastMore); + }); + } + + activate('tab-jellyfin'); +} + function init() { setText('year', new Date().getFullYear()); loadStatusHistory(); @@ -695,6 +741,7 @@ function init() { }); window.addEventListener('beforeunload', flushSaveHistory); + initTabs(); SERVICES.forEach((service) => refreshService(service)); } From e2a38d246332c774f38ed986afefb4d921283811 Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:43:14 +0200 Subject: [PATCH 08/12] Revert "feat(ui): surface services in responsive cards" This reverts commit 9069b512d735223176f49dba4c49f306d7a93b86. --- css/styles.css | 267 ++++++++++++++++++++++++---------------- index.html | 322 ++++++++++++++++++++++++++----------------------- 2 files changed, 338 insertions(+), 251 deletions(-) diff --git a/css/styles.css b/css/styles.css index 756a817..4689ca5 100644 --- a/css/styles.css +++ b/css/styles.css @@ -107,11 +107,21 @@ h1 { } .pc-status { - display: flex; + margin-top: 0; + display: inline-flex; align-items: center; - flex-wrap: wrap; gap: 8px; - margin-top: 14px; + white-space: nowrap; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #111b26, #0b111a); + font-weight: 600; + font-size: .92rem; +} + +.pc-status .pc-main { + font-weight: 700; } .pc-status .pc-os { @@ -126,6 +136,73 @@ h1 { line-height: 1.2; } +.pc-status.is-on .pc-main { + color: var(--good); +} + +.pc-status.is-off .pc-main { + color: var(--bad); +} + +nav.tabs { + display: flex; + gap: 8px; + flex-wrap: nowrap; + overflow: visible; + /* no scroll gutter, no clipping */ + scrollbar-width: none; + /* FF: hide scrollbar */ + flex: 0 1 auto; + /* don't steal width from brand */ +} + +nav.tabs::-webkit-scrollbar { + display: none; +} + +nav.tabs::-webkit-scrollbar { + display: none; +} + +.tab { + padding: 8px 16px; + border: 1px solid var(--line); + border-radius: 9999px; + background: var(--card); + color: var(--fg); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: transform .2s ease, background .2s ease; + position: relative; + /* for ::after ring */ + isolation: isolate; + /* create its own stacking context so ring isn't hidden */ +} + +.tab:hover { + transform: translateY(-2px); + background: linear-gradient(180deg, #111b26, #0b111a) +} + +/* Nice, always-visible ring for active tab */ +.tab[aria-selected="true"] { + outline: none +} + +.tab[aria-selected="true"]::after { + content: ""; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + /* extends evenly around */ + border-radius: 9999px; + border: 2px solid var(--acc); + pointer-events: none; +} + .controls { display: flex; gap: 8px; @@ -148,6 +225,7 @@ h1 { outline: 2px solid var(--acc) } +.tab:focus-visible, .chip:focus-visible, a:focus-visible { outline: 2px solid var(--acc); @@ -213,11 +291,18 @@ a:hover { box-shadow: var(--ring) } +.j-wrap { + display: flex; + gap: 18px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap +} + .j-left { display: flex; gap: 16px; - align-items: flex-start; - min-width: 0; + align-items: center } .j-icon { @@ -233,72 +318,6 @@ a:hover { height: 70px; } -.service-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; - margin-bottom: 18px; -} - -.section-heading { - grid-column: 1 / -1; -} - -.section-heading h2 { - margin: 0; -} - -.service-card { - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: space-between; - gap: 18px; - min-height: 218px; - margin-bottom: 0; -} - -.service-card-copy { - min-width: 0; -} - -.service-card h2 { - margin: 0 0 6px; -} - -.service-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} - -.service-status .dot { - background: var(--mut); -} - -.service-status.status-online .dot { - background: var(--good); -} - -.service-status.status-unreachable .dot { - background: var(--bad); -} - -.service-status.status-unknown .dot { - background: var(--mut); -} - -.service-latency { - color: var(--mut); -} - -.service-note { - margin: 10px 0 0; - color: var(--mut); - font-size: .85rem; -} - .pill { display: inline-flex; align-items: center; @@ -317,19 +336,14 @@ a:hover { } .status-history { - margin-bottom: 18px; + margin-top: 16px; } -.status-history > summary, -.client-info > summary { - cursor: pointer; +.status-history h3 { + margin: 0 0 10px; + color: var(--mut); + font-size: .95rem; font-weight: 700; - list-style-position: outside; -} - -.status-history[open] > summary, -.client-info[open] > summary { - margin-bottom: 14px; } .history-grid { @@ -373,10 +387,6 @@ a:hover { color: var(--good); } -.history-summary.is-collecting { - color: var(--mut); -} - .history-summary.is-recovering { color: #f59e0b; } @@ -466,9 +476,10 @@ footer { color: #0b1220 } +.theme-light .tab, .theme-light .chip, .theme-light .pill, -.theme-light .pc-status .pc-os { +.theme-light .pc-status { background: linear-gradient(180deg, #ffffff, #eef3f9); border-color: #d9e1ec; color: #0b1220 @@ -478,9 +489,11 @@ footer { background: radial-gradient(1400px 800px at 10% -10%, #ffffff 0, #f2f5fa 60%) fixed } +.theme-light .tab, .theme-light .chip, .theme-light .btn, -.theme-light .pill { +.theme-light .pill, +.theme-light .pc-status { background: linear-gradient(180deg, #f9fafb, #f3f4f6); color: var(--fg) } @@ -489,8 +502,7 @@ footer { background: var(--card) } -.theme-light .history-row, -.theme-light .client-info { +.theme-light .history-row { background: #f9fafb; } @@ -498,6 +510,28 @@ footer { color: #d97706; } +.toast { + position: fixed; + left: 50%; + bottom: 20px; + transform: translate(-50%, 10px); + background: var(--card); + color: var(--fg); + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px 14px; + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transition: opacity .2s ease, transform .2s ease; + z-index: 50; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 0) +} + .theme-light .topbar { background: rgba(255, 255, 255, 0.86); border-bottom: 1px solid var(--line); @@ -533,13 +567,17 @@ footer { .theme-oled .card, .theme-oled .btn, +.theme-oled .tab, .theme-oled .chip, -.theme-oled .pill { +.theme-oled .pill, +.theme-oled .pc-status, +.theme-oled .toast { background: #000; border-color: var(--line); color: var(--fg); } +.theme-oled .tab:hover, .theme-oled .btn:hover, .theme-oled .chip:hover { background: #050505; @@ -618,6 +656,12 @@ html.theme-light #theme-toggle { left: 0; } +.card.active-card { + outline: 3px solid var(--acc); + outline-offset: 4px; + border-radius: 1rem +} + .github-link { display: inline-flex; align-items: center; @@ -631,17 +675,21 @@ html.theme-light #theme-toggle { text-decoration: underline; } -.campaign-link { - font-size: .85rem; -} - @media (max-width: 900px) { .controls { flex: 1 1 100%; width: 100%; } - .pc-status .pc-os { + .pc-status { + width: 100%; + white-space: normal; + flex-wrap: wrap; + row-gap: 6px; + } + + .pc-status .pc-os, + .pc-status .pc-main { max-width: 100%; overflow-wrap: anywhere; } @@ -665,11 +713,26 @@ html.theme-light #theme-toggle { align-items: flex-start; } + nav.tabs { + flex: 1 1 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 4px; + } + + .tab[aria-selected="true"]::after { + top: 0; + right: 0; + bottom: 0; + left: 0; + } + .controls { gap: 10px; } - .chip { + .chip, + .tab { padding: 10px 12px; min-height: 44px; } @@ -678,6 +741,12 @@ html.theme-light #theme-toggle { padding: 16px; } + .j-wrap { + flex-direction: column; + align-items: stretch; + gap: 14px; + } + .j-left { align-items: flex-start; } @@ -688,11 +757,7 @@ html.theme-light #theme-toggle { height: 56px; } - .service-grid { - grid-template-columns: 1fr; - } - - .service-card .btn { + .btn { width: 100%; justify-content: center; min-height: 44px; diff --git a/index.html b/index.html index ef26c16..531f991 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@ - +
@@ -31,6 +31,19 @@

adiker.eu

Usually available: 9:00 AM – 1:00 AM CEST
+
@@ -39,200 +52,211 @@

adiker.eu

+
My PC is off :(
-
-

📊 Service status

-

Live health checks from this browser

+
+

📊 Service dashboard

+

Live status overview

- Checking… 0/5 online - Last update: -- + 0/5 online + Last check: -- + Jellyfin: -- ms + FileBrowser: -- ms + autobrr: -- ms + OpenSpeedTest: -- ms + Muse: -- ms +
+
+

Recent checks

+
+
+
+ Jellyfin + No data yet +
+ + Avg: -- ms +
+
+
+ FileBrowser + No data yet +
+ + Avg: -- ms +
+
+
+ autobrr + No data yet +
+ + Avg: -- ms +
+
+
+ OpenSpeedTest + No data yet +
+ + Avg: -- ms +
+
+
+ Muse + No data yet +
+ + Avg: -- ms +
+
-
-
-

Services

-
-
+
+
-
-

🎬 Jellyfin

-

Your media server

-
- - Checking… - - Latency: -- ms +
+

🎬 Jellyfin

+

Your media server

+
+ Offline
- - - Open Jellyfin - -
- -
+ + + + + - -
- Recent measurements -
-
-
- Jellyfin - No data yet -
- - Avg: -- ms -
-
-
- FileBrowser - No data yet -
- - Avg: -- ms -
-
-
- autobrr - No data yet -
- - Avg: -- ms -
-
-
- OpenSpeedTest - No data yet -
- - Avg: -- ms -
-
-
- Muse - No data yet -
- - Avg: -- ms -
-
- -
- Client information -
-
+ +
+ From bfce02c48c7146cd2d426378d3b66200aae0bddd Mon Sep 17 00:00:00 2001 From: Adiker Date: Mon, 14 Sep 2026 19:55:12 +0200 Subject: [PATCH 09/12] refactor(ui): keep compact service tabs --- README.md | 18 +++-- css/styles.css | 184 ++++++++++++++++++++++++++++++------------- index.html | 111 +++++++++++++++++--------- js/app.js | 210 ++++++++++++++++++++++++++++++------------------- 4 files changed, 346 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index f77b4ab..0f54a45 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,16 @@ server. ### Features -- Service tabs with a clean card-based layout -- Live service dashboard with online count, last check time, and latency labels -- Browser-local recent status history with mini timelines and average latency +- Service tabs with a compact status overview and one focused detail card +- Live dashboard showing every service's status and latency as measured by this browser +- Collapsed browser-local history of the last 24 measurements with mini timelines and averages - Health checks for all configured services +- Explicit `Checking`, `Online`, `Unreachable`, and `Unknown` service states - Local Muse readiness probe with Discord gateway connectivity check - Polling with warm-up measurement and failure backoff - EN/PL language switcher - Dark / Light / OLED themes -- Client info badge with OS, browser, and device detection +- Collapsed client information with OS, browser, and device detection - Keyboard-accessible tabs and visible focus states ### Project Structure @@ -210,15 +211,16 @@ albo dowolnym serwerze statycznym. ### Funkcje -- Zakładki usług z czytelnym układem kart -- Dashboard statusów z liczbą usług online, czasem ostatniego sprawdzenia i opóźnieniami -- Lokalna historia ostatnich statusów z mini-timeline i średnim opóźnieniem +- Zakładki usług z kompaktowym podsumowaniem i jednym aktywnym panelem szczegółów +- Dashboard pokazujący status i opóźnienie każdej usługi, mierzone z tej przeglądarki +- Zwijana lokalna historia ostatnich 24 pomiarów z mini-timeline i średnim opóźnieniem - Health checki dla wszystkich skonfigurowanych usług +- Jawne stany usług: `Checking`, `Online`, `Unreachable` i `Unknown` - Lokalna sonda gotowości Muse ze sprawdzeniem połączenia z gateway Discorda - Odpytywanie z pomiarem rozgrzewkowym i backoffem przy błędach - Przełącznik języka EN/PL - Motywy Dark / Light / OLED -- Etykieta z wykrytym OS, przeglądarką i typem urządzenia +- Zwijane informacje o kliencie: OS, przeglądarka i typ urządzenia - Zakładki dostępne z klawiatury i czytelne stany focus ### Struktura projektu diff --git a/css/styles.css b/css/styles.css index 4689ca5..4b5f08e 100644 --- a/css/styles.css +++ b/css/styles.css @@ -107,23 +107,16 @@ h1 { } .pc-status { - margin-top: 0; - display: inline-flex; + margin-top: 14px; + display: flex; align-items: center; gap: 8px; - white-space: nowrap; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--line); - background: linear-gradient(180deg, #111b26, #0b111a); + flex-wrap: wrap; + white-space: normal; font-weight: 600; font-size: .92rem; } -.pc-status .pc-main { - font-weight: 700; -} - .pc-status .pc-os { display: inline-flex; align-items: center; @@ -136,14 +129,6 @@ h1 { line-height: 1.2; } -.pc-status.is-on .pc-main { - color: var(--good); -} - -.pc-status.is-off .pc-main { - color: var(--bad); -} - nav.tabs { display: flex; gap: 8px; @@ -227,7 +212,8 @@ nav.tabs::-webkit-scrollbar { .tab:focus-visible, .chip:focus-visible, -a:focus-visible { +a:focus-visible, +summary:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; } @@ -335,10 +321,121 @@ a:hover { gap: 8px; } +.dashboard-services { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 14px; +} + +.dashboard-service { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: color-mix(in srgb, var(--card) 88%, transparent); + font-size: .9rem; +} + +.dashboard-service-name { + min-width: 0; + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-service-state, +.dashboard-service-latency { + color: var(--mut); + font-size: .82rem; + font-weight: 600; + white-space: nowrap; +} + +.dashboard-service.status-online .dot, +.service-status.status-online .dot { + background: var(--good); +} + +.dashboard-service.status-checking .dot, +.dashboard-service.status-unknown .dot, +.service-status.status-checking .dot, +.service-status.status-unknown .dot { + background: var(--mut); +} + +.dashboard-service.status-unreachable .dot, +.service-status.status-unreachable .dot { + background: var(--bad); +} + +.dashboard-service.status-online .dashboard-service-state { + color: var(--good); +} + +.dashboard-service.status-unreachable .dashboard-service-state { + color: var(--bad); +} + +.dashboard-grid #dash-overall.is-checking { + color: var(--mut); +} + +.dashboard-grid #dash-overall.is-unreachable { + color: var(--bad); +} + +.service-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.service-status .dot { + background: var(--mut); +} + +.service-status.status-online { + color: var(--good); +} + +.service-status.status-unreachable { + color: var(--bad); +} + +.service-latency { + color: var(--mut); +} + +.service-source, +.service-note { + margin: 8px 0 0; + color: var(--mut); + font-size: .76rem; +} + .status-history { margin-top: 16px; } +.client-info > summary, +.status-history > summary { + cursor: pointer; + font-weight: 700; + list-style-position: outside; +} + +.client-info[open] > summary, +.status-history[open] > summary { + margin-bottom: 14px; +} + .status-history h3 { margin: 0 0 10px; color: var(--mut); @@ -387,6 +484,10 @@ a:hover { color: var(--good); } +.history-summary.is-collecting { + color: var(--mut); +} + .history-summary.is-recovering { color: #f59e0b; } @@ -413,7 +514,7 @@ a:hover { } .history-segment.is-online:hover, -.history-segment.is-offline:hover { +.history-segment.is-unreachable:hover { transform: scaleY(1.3); } @@ -421,7 +522,7 @@ a:hover { background: var(--good); } -.history-segment.is-offline { +.history-segment.is-unreachable { background: var(--bad); } @@ -478,8 +579,7 @@ footer { .theme-light .tab, .theme-light .chip, -.theme-light .pill, -.theme-light .pc-status { +.theme-light .pill { background: linear-gradient(180deg, #ffffff, #eef3f9); border-color: #d9e1ec; color: #0b1220 @@ -492,8 +592,7 @@ footer { .theme-light .tab, .theme-light .chip, .theme-light .btn, -.theme-light .pill, -.theme-light .pc-status { +.theme-light .pill { background: linear-gradient(180deg, #f9fafb, #f3f4f6); color: var(--fg) } @@ -510,28 +609,6 @@ footer { color: #d97706; } -.toast { - position: fixed; - left: 50%; - bottom: 20px; - transform: translate(-50%, 10px); - background: var(--card); - color: var(--fg); - border: 1px solid var(--line); - border-radius: 12px; - padding: 10px 14px; - box-shadow: var(--shadow); - opacity: 0; - pointer-events: none; - transition: opacity .2s ease, transform .2s ease; - z-index: 50; -} - -.toast.show { - opacity: 1; - transform: translate(-50%, 0) -} - .theme-light .topbar { background: rgba(255, 255, 255, 0.86); border-bottom: 1px solid var(--line); @@ -569,9 +646,7 @@ footer { .theme-oled .btn, .theme-oled .tab, .theme-oled .chip, -.theme-oled .pill, -.theme-oled .pc-status, -.theme-oled .toast { +.theme-oled .pill { background: #000; border-color: var(--line); color: var(--fg); @@ -688,8 +763,7 @@ html.theme-light #theme-toggle { row-gap: 6px; } - .pc-status .pc-os, - .pc-status .pc-main { + .pc-status .pc-os { max-width: 100%; overflow-wrap: anywhere; } @@ -774,6 +848,10 @@ html.theme-light #theme-toggle { justify-content: center; } + .dashboard-services { + grid-template-columns: 1fr; + } + .history-grid { grid-template-columns: 1fr; } diff --git a/index.html b/index.html index 531f991..d4f645c 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,7 @@ - +
@@ -42,7 +42,6 @@

adiker.eu

type="button">OpenSpeedTest -
@@ -52,7 +51,6 @@

adiker.eu

-
My PC is off :(
@@ -63,15 +61,42 @@

📊 Service dashboard

Live status overview

0/5 online - Last check: -- - Jellyfin: -- ms - FileBrowser: -- ms - autobrr: -- ms - OpenSpeedTest: -- ms - Muse: -- ms + Last update: --
-
-

Recent checks

+
+
+ + Jellyfin + Checking… + -- ms +
+
+ + FileBrowser + Checking… + -- ms +
+
+ + autobrr + Checking… + -- ms +
+
+ + OpenSpeedTest + Checking… + -- ms +
+
+ + Muse + Checking… + -- ms +
+
+
+ Recent measurements
@@ -119,7 +144,7 @@

Recent checks

Avg: -- ms
-
+
@@ -132,10 +157,13 @@

Recent checks

🎬 Jellyfin

Your media server

-
- Offline +
+ + Checking… + + Latency: -- ms
+

Measured from this browser

+
+ Client information +
+
-
- - - + + diff --git a/js/app.js b/js/app.js index 6c66e4a..438618a 100644 --- a/js/app.js +++ b/js/app.js @@ -3,6 +3,7 @@ const LANG_KEY = 'adiker.lang'; const STATUS_HISTORY_KEY = 'adiker.statusHistory.v1'; const THEME_ORDER = ['dark', 'light', 'oled']; const HISTORY_LIMIT = 24; +const MIN_STABLE_SAMPLES = 6; let __animTheme = false; let lastCheckAt = null; @@ -13,11 +14,11 @@ const DETECTED_BROWSER = detectClientBrowser(); const DETECTED_DEVICE = detectClientDevice(); const SERVICE_STATE = { - jf: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - fb: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - ab: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - st: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, - mu: { online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } + jf: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + fb: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + ab: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + st: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] }, + mu: { status: 'checking', online: false, latency: null, failCount: 0, nextDelay: 5000, warmedUp: false, latencySamples: [], history: [] } }; const SERVICES = [ @@ -30,13 +31,13 @@ const SERVICES = [ const STR = { en: { + documentTitle: 'Homepage', nav: { jellyfin: 'Jellyfin', filebrowser: 'Filebrowser', autobrr: 'Autobrr', openspeedtest: 'OpenSpeedTest', - muse: 'Muse', - more: 'More services (soon)' + muse: 'Muse' }, subtitle: 'My Playground', availability: 'Usually available: 9:00 AM – 1:00 AM CEST', @@ -45,19 +46,27 @@ const STR = { themeTitle: 'Toggle theme', language: 'Language', sections: 'Sections', + services: 'Service details', + skipLink: 'Skip to main content', + clientInfo: 'Client information', + latency: 'Latency', + source: 'Measured from this browser', dark: 'Dark', light: 'Light', oled: 'OLED' }, dashboard: { title: '📊 Service dashboard', - sub: 'Live status overview', + sub: 'Live status overview from this browser', overall: 'online', - lastCheck: 'Last check', + checking: 'Checking…', + unreachable: 'unreachable', + lastCheck: 'Last update', notYet: '--', - historyTitle: 'Recent checks', + historyTitle: 'Recent measurements (last 24)', avgLatency: 'Avg', noData: 'No data yet', + collecting: 'Collecting data', stable: 'Stable', recovering: 'Recovering', issues: 'Issues detected' @@ -66,11 +75,10 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Your file manager', open: 'Open FileBrowser Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automated torrent management', open: 'Open autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Network speed test', open: 'Open OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Discord music bot', short: 'Muse' }, - status: { online: 'Online', offline: 'Offline' }, + mu: { title: 'Muse', sub: 'Discord music bot', note: 'Status only · no web interface', short: 'Muse' }, + status: { checking: 'Checking…', online: 'Online', unreachable: 'Unreachable', unknown: 'Unknown' }, + statusSource: 'Status measured from this browser', pc: { - on: 'My PC is on :)', - off: 'My PC is off :(', osPrefix: 'Your OS', browserPrefix: 'Your browser', devicePrefix: 'Your device', @@ -104,17 +112,16 @@ const STR = { other: 'Other' } }, - footer: { served: 'Served by GitHub/Caddy' }, - toastMore: 'Coming soon 🙂' + footer: { served: 'Served by GitHub/Caddy', campaign: 'Keep Android Open' } }, pl: { + documentTitle: 'Strona główna', nav: { jellyfin: 'Jellyfin', filebrowser: 'Filebrowser', autobrr: 'Autobrr', openspeedtest: 'OpenSpeedTest', - muse: 'Muse', - more: 'Więcej usług (wkrótce)' + muse: 'Muse' }, subtitle: 'Mój plac zabaw', availability: 'Zwykle dostępny: 09:00 – 01:00 CEST', @@ -123,19 +130,27 @@ const STR = { themeTitle: 'Przełącz motyw', language: 'Język', sections: 'Sekcje', + services: 'Szczegóły usług', + skipLink: 'Przejdź do treści głównej', + clientInfo: 'Informacje o kliencie', + latency: 'Opóźnienie', + source: 'Pomiar z tej przeglądarki', dark: 'Ciemny', light: 'Jasny', oled: 'OLED' }, dashboard: { title: '📊 Panel usług', - sub: 'Podgląd statusu na żywo', + sub: 'Bieżące pomiary z tej przeglądarki', overall: 'online', - lastCheck: 'Ostatnie sprawdzenie', + checking: 'Sprawdzanie…', + unreachable: 'bez odpowiedzi', + lastCheck: 'Ostatnia aktualizacja', notYet: '--', - historyTitle: 'Ostatnie sprawdzenia', + historyTitle: 'Ostatnie pomiary (24)', avgLatency: 'Śr.', noData: 'Brak danych', + collecting: 'Zbieranie danych', stable: 'Stabilnie', recovering: 'Wraca do normy', issues: 'Wykryto problemy' @@ -144,11 +159,10 @@ const STR = { fb: { title: 'FileBrowser Quantum', sub: 'Twój menedżer plików', open: 'Otwórz FileBrowsera Quantum', short: 'FileBrowser' }, ab: { title: 'autobrr', sub: 'Automatyzacja torrentów', open: 'Otwórz autobrr', short: 'autobrr' }, st: { title: 'OpenSpeedTest', sub: 'Test prędkości sieci', open: 'Otwórz OpenSpeedTest', short: 'OpenSpeedTest' }, - mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', short: 'Muse' }, - status: { online: 'Online', offline: 'Offline' }, + mu: { title: 'Muse', sub: 'Bot muzyczny Discorda', note: 'Tylko status · brak interfejsu WWW', short: 'Muse' }, + status: { checking: 'Sprawdzanie…', online: 'Online', unreachable: 'Brak odpowiedzi', unknown: 'Nieznany' }, + statusSource: 'Status mierzony z tej przeglądarki', pc: { - on: 'Mój PC jest włączony :)', - off: 'Mój PC jest wyłączony :(', osPrefix: 'Twój OS', browserPrefix: 'Twoja przeglądarka', devicePrefix: 'Twoje urządzenie', @@ -182,8 +196,7 @@ const STR = { other: 'Inne' } }, - footer: { served: 'Hostowane przez GitHub/Caddy' }, - toastMore: 'Wkrótce 🙂' + footer: { served: 'Hostowane przez GitHub/Caddy', campaign: 'Keep Android Open' } } }; @@ -277,14 +290,6 @@ function setText(id, value) { if (el) el.textContent = value; } -function showToast(msg) { - const t = document.getElementById('toast'); - if (!t) return; - t.textContent = msg; - t.classList.add('show'); - setTimeout(() => t.classList.remove('show'), 1600); -} - function getServiceLabel(key, lang = getLang()) { const L = STR[lang] || STR.en; if (L[key] && L[key].short) return L[key].short; @@ -380,6 +385,7 @@ function getHistorySummaryType(history) { const lastOnline = history[history.length - 1].online; if (failures >= 2) return 'issues'; + if (failures === 0 && history.length < MIN_STABLE_SAMPLES) return 'collecting'; if (failures === 0) return 'stable'; return lastOnline ? 'recovering' : 'issues'; } @@ -398,13 +404,13 @@ function renderHistoryTimeline(key, lang, L) { const summary = L.dashboard[summaryType]; const avgLatency = getAverageHistoryLatency(history); const onlineCount = history.filter((entry) => entry.online).length; - const offlineCount = history.length - onlineCount; + const unreachableCount = history.length - onlineCount; setText(`history-${key}-name`, label); const summaryEl = document.getElementById(`history-${key}-summary`); if (summaryEl) { summaryEl.textContent = summary; - summaryEl.classList.remove('is-stable', 'is-recovering', 'is-issues'); + summaryEl.classList.remove('is-stable', 'is-collecting', 'is-recovering', 'is-issues'); if (summaryType !== 'noData') summaryEl.classList.add(`is-${summaryType}`); } setText(`history-${key}-avg`, `${L.dashboard.avgLatency}: ${avgLatency ?? '--'} ms`); @@ -427,14 +433,14 @@ function renderHistoryTimeline(key, lang, L) { for (let i = 0; i < HISTORY_LIMIT; i += 1) { const segment = line.children[i]; const entry = history[i]; - segment.classList.remove('is-online', 'is-offline', 'is-unknown'); + segment.classList.remove('is-online', 'is-unreachable', 'is-unknown'); if (!entry) { segment.classList.add('is-unknown'); segment.title = L.dashboard.noData; } else { - segment.classList.add(entry.online ? 'is-online' : 'is-offline'); + segment.classList.add(entry.online ? 'is-online' : 'is-unreachable'); const time = new Date(entry.at).toLocaleTimeString(locale, timeOpts); - const status = entry.online ? L.status.online : L.status.offline; + const status = entry.online ? L.status.online : L.status.unreachable; segment.title = entry.online && entry.latency != null ? `${status} — ${entry.latency} ms — ${time}` : `${status} — ${time}`; @@ -442,60 +448,83 @@ function renderHistoryTimeline(key, lang, L) { } const ariaText = history.length - ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${offlineCount} ${L.status.offline}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` + ? `${label}: ${summary}. ${onlineCount} ${L.status.online}, ${unreachableCount} ${L.status.unreachable}, ${L.dashboard.avgLatency}: ${avgLatency ?? L.dashboard.notYet} ms` : `${label}: ${L.dashboard.noData}`; line.setAttribute('aria-label', ariaText); } function renderPcStatus(lang = getLang()) { const L = STR[lang] || STR.en; - const anyOnline = Object.values(SERVICE_STATE).some((s) => s.online); const pcStatus = document.getElementById('pc-status'); if (!pcStatus) return; const osLabel = L.pc.os[DETECTED_OS] || L.pc.os.other; const browserLabel = L.pc.browser[DETECTED_BROWSER] || L.pc.browser.other; const deviceLabel = L.pc.device[DETECTED_DEVICE] || L.pc.device.other; - const pcText = anyOnline ? L.pc.on : L.pc.off; pcStatus.innerHTML = ` - ${pcText} ${L.pc.osPrefix}: ${osLabel} ${L.pc.browserPrefix}: ${browserLabel} ${L.pc.devicePrefix}: ${deviceLabel} `; - pcStatus.classList.toggle('is-on', anyOnline); - pcStatus.classList.toggle('is-off', !anyOnline); +} + +function renderDashboardService(key, lang, L) { + const state = SERVICE_STATE[key]; + const row = document.getElementById(`dash-${key}-status`); + if (!state || !row) return; + + const statusLabel = L.status[state.status] || L.status.unknown; + row.classList.remove('status-checking', 'status-online', 'status-unreachable', 'status-unknown'); + row.classList.add(`status-${state.status}`); + setText(`dash-${key}-name`, getServiceLabel(key, lang)); + setText(`dash-${key}-text`, statusLabel); + setText(`dash-${key}-latency`, `${state.latency ?? '--'} ms`); + row.setAttribute('aria-label', `${getServiceLabel(key, lang)}: ${statusLabel}. ${L.statusSource}`); } function renderDashboard(lang = getLang()) { const L = STR[lang] || STR.en; const total = SERVICES.length; - const onlineCount = Object.values(SERVICE_STATE).filter((s) => s.online).length; + const states = Object.values(SERVICE_STATE); + const onlineCount = states.filter((s) => s.status === 'online').length; + const checkingCount = states.filter((s) => s.status === 'checking' || s.status === 'unknown').length; + const unreachableCount = states.filter((s) => s.status === 'unreachable').length; - setText('dash-overall', `${onlineCount}/${total} ${L.dashboard.overall}`); setText('dash-title', L.dashboard.title); setText('dash-sub', L.dashboard.sub); + const overall = document.getElementById('dash-overall'); + if (overall) { + const detail = checkingCount + ? `${L.dashboard.checking} ${checkingCount}` + : (unreachableCount ? `${unreachableCount} ${L.dashboard.unreachable}` : ''); + overall.textContent = detail + ? `${detail} · ${onlineCount}/${total} ${L.dashboard.overall}` + : `${onlineCount}/${total} ${L.dashboard.overall}`; + overall.classList.toggle('is-checking', checkingCount > 0); + overall.classList.toggle('is-unreachable', checkingCount === 0 && unreachableCount > 0); + } + const lastText = lastCheckAt ? new Date(lastCheckAt).toLocaleTimeString(lang === 'pl' ? 'pl-PL' : 'en-GB') : L.dashboard.notYet; setText('dash-lastcheck', `${L.dashboard.lastCheck}: ${lastText}`); - - setText('dash-jf-latency', `${L.jf.short}: ${SERVICE_STATE.jf.latency ?? '--'} ms`); - setText('dash-fb-latency', `${L.fb.short}: ${SERVICE_STATE.fb.latency ?? '--'} ms`); - setText('dash-ab-latency', `${L.ab.short}: ${SERVICE_STATE.ab.latency ?? '--'} ms`); - setText('dash-st-latency', `${L.st.short}: ${SERVICE_STATE.st.latency ?? '--'} ms`); - setText('dash-mu-latency', `${L.mu.short}: ${SERVICE_STATE.mu.latency ?? '--'} ms`); setText('history-title', L.dashboard.historyTitle); + setText('client-title', L.ui.clientInfo); - SERVICES.forEach((service) => renderHistoryTimeline(service.key, lang, L)); + SERVICES.forEach((service) => { + renderDashboardService(service.key, lang, L); + renderHistoryTimeline(service.key, lang, L); + }); } function applyLang(lang) { const L = STR[lang] || STR.en; document.documentElement.lang = lang; + document.title = `adiker.eu — ${L.documentTitle}`; + setText('skip-link', L.ui.skipLink); setText('jf-title', '🎬 ' + L.jf.title); setText('jf-sub', L.jf.sub); setText('jf-btn-text', L.jf.open); @@ -510,10 +539,12 @@ function applyLang(lang) { setText('st-btn-text', L.st.open); setText('mu-title', '🎧 ' + L.mu.title); setText('mu-sub', L.mu.sub); + setText('mu-note', L.mu.note); setText('subtitle', L.subtitle); setText('availability', L.availability); setText('served', L.footer.served); + setText('campaign-link', L.footer.campaign); const jfBtn = document.getElementById('jf-btn'); const fbBtn = document.getElementById('fb-btn'); @@ -529,10 +560,11 @@ function applyLang(lang) { setText('tab-autobrr', L.nav.autobrr); setText('tab-speedtest', L.nav.openspeedtest); setText('tab-muse', L.nav.muse); - setText('tab-more', L.nav.more); const tabsNav = document.querySelector('nav.tabs'); if (tabsNav) tabsNav.setAttribute('aria-label', L.ui.sections); + const dashboardServices = document.getElementById('dashboard-services'); + if (dashboardServices) dashboardServices.setAttribute('aria-label', L.ui.services); const langGroup = document.querySelector('[role="group"]'); if (langGroup) langGroup.setAttribute('aria-label', L.ui.language); @@ -549,7 +581,9 @@ function applyLang(lang) { updateThemeUI(lang); renderPcStatus(lang); + SERVICES.forEach((service) => setText(`${service.key}-source`, L.ui.source)); renderDashboard(lang); + SERVICES.forEach((service) => renderServiceCard(service.key, lang)); localStorage.setItem(LANG_KEY, lang); } @@ -588,22 +622,43 @@ function updateThemeUI(lang) { } } -function setServiceStatus(prefix, ok, latency) { - const L = STR[getLang()] || STR.en; - const dot = document.getElementById(prefix + '-dot'); - const txt = document.getElementById(prefix + '-text'); - const pill = document.getElementById(prefix + '-pill'); +function renderServiceCard(prefix, lang = getLang()) { + const L = STR[lang] || STR.en; + const state = SERVICE_STATE[prefix]; + if (!state) return; + + const pill = document.getElementById(`${prefix}-pill`); + const txt = document.getElementById(`${prefix}-text`); + const latencyEl = document.getElementById(`${prefix}-latency`); + const label = getServiceLabel(prefix, lang); + const statusLabel = L.status[state.status] || L.status.unknown; + + if (pill) { + pill.classList.remove('status-checking', 'status-online', 'status-unreachable', 'status-unknown'); + pill.classList.add(`status-${state.status}`); + pill.setAttribute('aria-label', `${label}: ${statusLabel}. ${L.statusSource}`); + } + if (txt) txt.textContent = statusLabel; + if (latencyEl) latencyEl.textContent = `${L.ui.latency}: ${state.latency ?? '--'} ms`; +} - if (dot) dot.style.background = ok ? 'var(--good)' : 'var(--bad)'; - if (txt) txt.textContent = ok ? L.status.online : L.status.offline; - if (pill) pill.setAttribute('aria-label', ok ? L.status.online : L.status.offline); +function setServiceStatus(prefix, status, latency = null) { + const state = SERVICE_STATE[prefix]; + if (!state) return; - SERVICE_STATE[prefix].online = !!ok; - SERVICE_STATE[prefix].latency = Number.isFinite(latency) ? latency : null; - lastCheckAt = Date.now(); - addStatusHistoryEntry(prefix, ok, latency); + const validStatus = ['checking', 'online', 'unreachable', 'unknown'].includes(status) + ? status + : 'unknown'; + state.status = validStatus; + state.online = validStatus === 'online'; + state.latency = validStatus === 'online' && Number.isFinite(latency) ? latency : null; + renderServiceCard(prefix); + + if (validStatus === 'online' || validStatus === 'unreachable') { + lastCheckAt = Date.now(); + addStatusHistoryEntry(prefix, validStatus === 'online', state.latency); + } - renderPcStatus(); renderDashboard(); } @@ -616,6 +671,7 @@ function getSmoothedLatency(state, latency) { async function refreshService(service) { const state = SERVICE_STATE[service.key]; + if (state.status === 'unknown') setServiceStatus(service.key, 'checking'); const ctrl = new AbortController(); const timeout = setTimeout(() => ctrl.abort(), 2500); const started = performance.now(); @@ -635,22 +691,22 @@ async function refreshService(service) { // Pierwszy pomiar zwykle zawiera koszt DNS/TLS i potrafi być zawyżony. // Traktujemy go jako warm-up i pokazujemy stabilniejszy wynik z kolejnego sprawdzenia. state.warmedUp = true; - setServiceStatus(service.key, true, null); + setServiceStatus(service.key, 'online', null); state.failCount = 0; state.nextDelay = 800; } else if (ok) { const smoothedLatency = getSmoothedLatency(state, latency); - setServiceStatus(service.key, true, smoothedLatency); + setServiceStatus(service.key, 'online', smoothedLatency); state.failCount = 0; state.nextDelay = 15000; } else { - setServiceStatus(service.key, false, null); + setServiceStatus(service.key, 'unreachable'); state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); } } catch (_e) { clearTimeout(timeout); - setServiceStatus(service.key, false, null); + setServiceStatus(service.key, 'unreachable'); state.latencySamples = []; state.failCount += 1; state.nextDelay = Math.min(60000, 5000 * (2 ** state.failCount)); @@ -670,7 +726,6 @@ function initTabs() { }; const tabs = tabIds.map((id) => document.getElementById(id)).filter(Boolean); - const moreBtn = document.getElementById('tab-more'); function activate(tabId) { tabs.forEach((tab) => { @@ -700,13 +755,6 @@ function initTabs() { }); }); - if (moreBtn) { - moreBtn.addEventListener('click', () => { - const L = STR[getLang()] || STR.en; - showToast(L.toastMore); - }); - } - activate('tab-jellyfin'); } From ef02dfdb58adf8712b454938358620469df2eb83 Mon Sep 17 00:00:00 2001 From: Adiker Date: Thu, 17 Sep 2026 18:55:24 +0200 Subject: [PATCH 10/12] feat(ui): redesign compact service hub --- README.md | 8 +- css/styles.css | 1182 ++++++++++++++++++++++++++++-------------------- index.html | 103 +++-- js/app.js | 47 +- 4 files changed, 790 insertions(+), 550 deletions(-) diff --git a/README.md b/README.md index 0f54a45..1cb36db 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ server. ### Features -- Service tabs with a compact status overview and one focused detail card +- Responsive service tabs with a compact status overview and one focused detail card - Live dashboard showing every service's status and latency as measured by this browser - Collapsed browser-local history of the last 24 measurements with mini timelines and averages - Health checks for all configured services @@ -55,7 +55,7 @@ server. - Polling with warm-up measurement and failure backoff - EN/PL language switcher - Dark / Light / OLED themes -- Collapsed client information with OS, browser, and device detection +- Always-visible client context in the header with OS, browser, and device detection - Keyboard-accessible tabs and visible focus states ### Project Structure @@ -211,7 +211,7 @@ albo dowolnym serwerze statycznym. ### Funkcje -- Zakładki usług z kompaktowym podsumowaniem i jednym aktywnym panelem szczegółów +- Responsywne zakładki usług z kompaktowym podsumowaniem i jednym aktywnym panelem szczegółów - Dashboard pokazujący status i opóźnienie każdej usługi, mierzone z tej przeglądarki - Zwijana lokalna historia ostatnich 24 pomiarów z mini-timeline i średnim opóźnieniem - Health checki dla wszystkich skonfigurowanych usług @@ -220,7 +220,7 @@ albo dowolnym serwerze statycznym. - Odpytywanie z pomiarem rozgrzewkowym i backoffem przy błędach - Przełącznik języka EN/PL - Motywy Dark / Light / OLED -- Zwijane informacje o kliencie: OS, przeglądarka i typ urządzenia +- Zawsze widoczne w nagłówku informacje o kliencie: OS, przeglądarka i typ urządzenia - Zakładki dostępne z klawiatury i czytelne stany focus ### Struktura projektu diff --git a/css/styles.css b/css/styles.css index 4b5f08e..4fb01f4 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,148 +1,265 @@ :root { - --bg: #0b0f14; - --fg: #e6eef6; - --mut: #9fb3c8; - --line: #1b2530; - --card: #0f1620; - --acc: #7cc4ff; - --br: 16px; - --shadow: 0 12px 30px rgba(0, 0, 0, .35); - --ring: 0 0 0 2px #0ea5e955; - --good: #22c55e; - --bad: #ef4444; + --bg: #080d14; + --fg: #edf5ff; + --mut: #93a8bc; + --line: rgba(148, 177, 204, .16); + --card: #0e1621; + --surface: #121c28; + --surface-strong: #172434; + --acc: #67bdff; + --acc-strong: #169bf5; + --good: #31d477; + --bad: #ff5e68; + --warn: #f6b73c; + --br: 20px; + --shadow: 0 22px 60px rgba(0, 0, 0, .32); + --ring: 0 0 0 3px rgba(22, 155, 245, .28); } * { - box-sizing: border-box + box-sizing: border-box; } -html, -body { - height: 100% +html { + min-height: 100%; + color-scheme: dark; } body { + min-height: 100%; margin: 0; - font: 16px/1.55 system-ui, Segoe UI, Roboto, Ubuntu, Arial; - background: radial-gradient(1400px 800px at 10% -10%, #0e1622 0, #0b0f14 45%) fixed; + font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--fg); + background: + radial-gradient(circle at 12% -8%, rgba(14, 165, 233, .16), transparent 34rem), + radial-gradient(circle at 92% 18%, rgba(99, 102, 241, .1), transparent 28rem), + linear-gradient(180deg, #0a1018 0, var(--bg) 42rem) fixed; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; - transition: background .3s ease, color .3s ease; + transition: background .25s ease, color .25s ease; +} + +button, +a, +summary { + -webkit-tap-highlight-color: transparent; } .skip-link { - position: absolute; + position: fixed; + top: 12px; left: 12px; - top: -48px; z-index: 100; - padding: 10px 12px; - border-radius: 10px; + padding: 10px 14px; border: 1px solid var(--line); - background: var(--card); + border-radius: 12px; color: var(--fg); - font-weight: 700; - transition: top .2s ease; + background: var(--surface-strong); + box-shadow: var(--shadow); + font-weight: 750; + transform: translateY(-180%); + transition: transform .18s ease; } .skip-link:focus { - top: 12px; + transform: translateY(0); +} + +.wrap, +.topbar-inner { + width: min(100%, 1080px); + margin-inline: auto; } .wrap { - max-width: 980px; - margin: 0 auto; - padding: 28px + padding: 26px 28px 10px; } .topbar { position: sticky; top: 0; z-index: 10; - backdrop-filter: saturate(140%) blur(6px); - background: rgba(11, 15, 20, .75); border-bottom: 1px solid var(--line); - overflow: visible; - /* ensure nothing clips */ + background: rgba(8, 13, 20, .82); + backdrop-filter: saturate(145%) blur(18px); } .topbar-inner { - max-width: 980px; - margin: 0 auto; - padding: 12px 28px; + display: grid; + gap: 12px; + padding: 14px 28px; +} + +.topbar-primary, +.topbar-lower { display: flex; align-items: center; justify-content: space-between; - gap: 18px; - overflow: visible; - /* ensure nothing clips */ - flex-wrap: wrap; - /* allow wrapping on narrower widths so subtitle never disappears */ + gap: 16px; + min-width: 0; } .brand { display: flex; - gap: 14px; align-items: center; - flex: 1 1 320px; - min-width: 280px; + gap: 14px; + min-width: 0; } .logo { - width: 42px; - height: 42px; - border-radius: 12px; - background: radial-gradient(70% 70% at 30% 30%, #0ea5e9, #1f2937); - box-shadow: 0 0 0 1px var(--line) inset; + position: relative; + flex: 0 0 auto; + width: 48px; + height: 48px; + border: 1px solid rgba(125, 211, 252, .22); + border-radius: 15px; + background: + radial-gradient(circle at 30% 25%, #38bdf8 0, #1679b8 38%, transparent 39%), + linear-gradient(145deg, #17344b, #0c1722); + box-shadow: 0 12px 28px rgba(14, 165, 233, .2), inset 0 1px rgba(255, 255, 255, .08); +} + +.logo::after { + content: ""; + position: absolute; + right: 9px; + bottom: 9px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 10px solid #bce7ff; + filter: drop-shadow(0 2px 5px rgba(0, 0, 0, .35)); +} + +h1, +h2, +p { + margin-top: 0; } h1 { - margin: 0; + margin-bottom: 0; + font-size: 1.35rem; + font-weight: 800; + letter-spacing: -.025em; +} + +h2 { + margin: 0 0 5px; font-size: 1.25rem; - font-weight: 700 + line-height: 1.25; + letter-spacing: -.02em; } .tag { - color: var(--mut) + color: var(--mut); } -.pc-status { - margin-top: 14px; +#subtitle { + display: inline; + font-size: .88rem; +} + +#availability { display: flex; align-items: center; - gap: 8px; - flex-wrap: wrap; - white-space: normal; - font-weight: 600; - font-size: .92rem; + gap: 7px; + margin-top: 2px; + font-size: .76rem; } -.pc-status .pc-os { - display: inline-flex; +#availability::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 0 4px rgba(49, 212, 119, .1); +} + +.controls { + display: flex; align-items: center; - padding: 2px 8px; - border-radius: 999px; + gap: 8px; + flex: 0 0 auto; +} + +.chip, +.tab { border: 1px solid var(--line); - background: color-mix(in srgb, var(--card) 88%, transparent); - color: var(--mut); - font-size: .8rem; - line-height: 1.2; + color: var(--fg); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.chip { + min-height: 38px; + padding: 8px 12px; + border-radius: 12px; + background: rgba(18, 28, 40, .82); + transition: border-color .16s ease, background .16s ease, transform .16s ease; +} + +.chip:hover { + border-color: rgba(103, 189, 255, .35); + background: var(--surface-strong); +} + +.language-control { + padding: 3px; +} + +.lang-switch { + position: relative; + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.lang-switch::before { + content: ""; + position: absolute; + inset: 0 50% 0 0; + border-radius: 9px; + background: linear-gradient(135deg, rgba(103, 189, 255, .32), rgba(22, 155, 245, .17)); + box-shadow: inset 0 0 0 1px rgba(103, 189, 255, .26); + transition: transform .22s cubic-bezier(.4, 0, .2, 1); +} + +.lang-switch:has(#lang-pl[aria-pressed="true"])::before, +.lang-switch.is-pl::before { + transform: translateX(100%); +} + +.lang-switch:has(#lang-en[aria-pressed="true"])::before, +.lang-switch.is-en::before { + transform: translateX(0); +} + +#lang-en, +#lang-pl { + position: relative; + z-index: 1; + min-width: 40px; + min-height: 32px; + padding: 5px 9px; + border: 0; + border-radius: 9px; + background: transparent; + box-shadow: none; } nav.tabs { display: flex; - gap: 8px; - flex-wrap: nowrap; - overflow: visible; - /* no scroll gutter, no clipping */ + gap: 3px; + min-width: 0; + padding: 4px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(9, 15, 23, .62); scrollbar-width: none; - /* FF: hide scrollbar */ - flex: 0 1 auto; - /* don't steal width from brand */ -} - -nav.tabs::-webkit-scrollbar { - display: none; } nav.tabs::-webkit-scrollbar { @@ -150,64 +267,92 @@ nav.tabs::-webkit-scrollbar { } .tab { - padding: 8px 16px; - border: 1px solid var(--line); - border-radius: 9999px; - background: var(--card); - color: var(--fg); - font-weight: 600; - cursor: pointer; + flex: 0 0 auto; + min-height: 36px; + padding: 7px 12px; + border-color: transparent; + border-radius: 10px; + color: var(--mut); + background: transparent; white-space: nowrap; - transition: transform .2s ease, background .2s ease; - position: relative; - /* for ::after ring */ - isolation: isolate; - /* create its own stacking context so ring isn't hidden */ + transition: color .16s ease, background .16s ease, transform .16s ease; } .tab:hover { - transform: translateY(-2px); - background: linear-gradient(180deg, #111b26, #0b111a) + color: var(--fg); + background: rgba(103, 189, 255, .08); } -/* Nice, always-visible ring for active tab */ .tab[aria-selected="true"] { - outline: none + color: #f5fbff; + background: linear-gradient(135deg, rgba(22, 155, 245, .34), rgba(74, 121, 255, .18)); + box-shadow: inset 0 0 0 1px rgba(103, 189, 255, .28), 0 8px 20px rgba(0, 0, 0, .15); } -.tab[aria-selected="true"]::after { - content: ""; - position: absolute; - top: -4px; - right: -4px; - bottom: -4px; - left: -4px; - /* extends evenly around */ - border-radius: 9999px; - border: 2px solid var(--acc); - pointer-events: none; +.client-info { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 4px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(9, 15, 23, .62); } -.controls { +.client-heading { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 7px; + color: var(--mut); + font-size: .72rem; + font-weight: 800; + letter-spacing: .02em; + white-space: nowrap; +} + +.client-indicator { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--acc); + box-shadow: 0 0 0 4px rgba(103, 189, 255, .1); +} + +.pc-status { display: flex; - gap: 8px; align-items: center; - flex-wrap: wrap; - flex: 0 0 auto; + gap: 4px; + min-width: 0; } -.chip { - padding: 8px 12px; - border: 1px solid var(--line); - border-radius: 999px; - background: linear-gradient(180deg, #111b26, #0b111a); - color: var(--fg); - cursor: pointer +.client-metric { + display: inline-flex; + align-items: baseline; + gap: 5px; + min-width: 0; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 9px; + background: rgba(18, 28, 40, .8); + white-space: nowrap; } -.chip[aria-pressed="true"], -.chip.active { - outline: 2px solid var(--acc) +.client-key { + color: var(--mut); + font-size: .64rem; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; +} + +.client-value { + overflow: hidden; + color: var(--fg); + font-size: .72rem; + font-weight: 800; + text-overflow: ellipsis; } .tab:focus-visible, @@ -215,151 +360,148 @@ nav.tabs::-webkit-scrollbar { a:focus-visible, summary:focus-visible { outline: 2px solid var(--acc); - outline-offset: 2px; + outline-offset: 3px; } main { - padding-top: 18px + padding-top: 2px; } .card { - background: var(--card); + margin-bottom: 16px; + padding: 24px; + overflow: hidden; border: 1px solid var(--line); border-radius: var(--br); - padding: 22px; - box-shadow: var(--shadow); - margin-bottom: 18px; - transition: transform .18s ease, box-shadow .18s ease; + background: linear-gradient(145deg, rgba(18, 28, 40, .94), rgba(12, 20, 30, .96)); + box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, .025); } -.card:hover { - transform: translateY(-2px); - box-shadow: 0 16px 36px rgba(0, 0, 0, .45) +.dashboard-card { + padding: 22px; } -h2 { - margin: .2rem 0 .6rem; - font-size: 1.2rem +.dashboard-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; } -a { - color: var(--acc); - text-decoration: none; - font-weight: 600 +.dashboard-copy .tag { + margin: 0; + font-size: .86rem; } -a:hover { - text-decoration: underline +.dashboard-grid { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + flex-wrap: wrap; } -.btn { +.pill { display: inline-flex; align-items: center; - gap: 10px; - padding: 12px 16px; - border-radius: 999px; + gap: 7px; + min-height: 32px; + padding: 6px 10px; border: 1px solid var(--line); - background: linear-gradient(180deg, #111b26, #0b111a); + border-radius: 10px; color: var(--fg); - text-decoration: none; + background: rgba(18, 28, 40, .78); + font-size: .86rem; font-weight: 700; - box-shadow: var(--shadow); - transition: transform .15s ease, filter .15s ease; -} - -.btn:hover { - filter: brightness(1.12); - transform: translateY(-1px) -} - -.btn:focus-visible { - outline: none; - box-shadow: var(--ring) -} - -.j-wrap { - display: flex; - gap: 18px; - align-items: center; - justify-content: space-between; - flex-wrap: wrap -} - -.j-left { - display: flex; - gap: 16px; - align-items: center } -.j-icon { - display: flex; - align-items: center; - justify-content: center; +.dashboard-grid .pill { + color: var(--mut); + font-size: .78rem; } -.j-icon svg, -.j-icon img { - display: block; - width: 70px; - height: 70px; +.dashboard-grid #dash-overall { + color: var(--fg); } -.pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border: 1px solid var(--line); - border-radius: 999px; - background: linear-gradient(180deg, #111b26, #0b111a); - color: var(--fg) +.dashboard-grid #dash-overall.is-checking { + color: var(--mut); } -.dashboard-grid { - display: flex; - flex-wrap: wrap; - gap: 8px; +.dashboard-grid #dash-overall.is-unreachable { + color: var(--bad); } .dashboard-services { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; - margin-top: 14px; + margin-top: 18px; } .dashboard-service { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + "dot name name" + "state state latency"; align-items: center; - gap: 8px; + gap: 8px 7px; min-width: 0; - padding: 8px 10px; + padding: 12px; border: 1px solid var(--line); - border-radius: 12px; - background: color-mix(in srgb, var(--card) 88%, transparent); - font-size: .9rem; + border-radius: 14px; + background: linear-gradient(145deg, rgba(23, 36, 52, .72), rgba(13, 22, 33, .76)); + transition: border-color .18s ease, background .18s ease, transform .18s ease; +} + +.dashboard-service:hover { + transform: translateY(-1px); + border-color: rgba(103, 189, 255, .26); +} + +.dashboard-service > .dot { + grid-area: dot; } .dashboard-service-name { + grid-area: name; min-width: 0; overflow: hidden; - font-weight: 700; + font-size: .86rem; + font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } +.dashboard-service-state { + grid-area: state; +} + +.dashboard-service-latency { + grid-area: latency; + justify-self: end; +} + .dashboard-service-state, .dashboard-service-latency { color: var(--mut); - font-size: .82rem; - font-weight: 600; + font-size: .72rem; + font-weight: 750; white-space: nowrap; } +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mut); +} + .dashboard-service.status-online .dot, .service-status.status-online .dot { background: var(--good); + box-shadow: 0 0 0 4px rgba(49, 212, 119, .1); } .dashboard-service.status-checking .dot, @@ -372,6 +514,7 @@ a:hover { .dashboard-service.status-unreachable .dot, .service-status.status-unreachable .dot { background: var(--bad); + box-shadow: 0 0 0 4px rgba(255, 94, 104, .1); } .dashboard-service.status-online .dashboard-service-state { @@ -382,82 +525,44 @@ a:hover { color: var(--bad); } -.dashboard-grid #dash-overall.is-checking { - color: var(--mut); -} - -.dashboard-grid #dash-overall.is-unreachable { - color: var(--bad); -} - -.service-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 12px; -} - -.service-status .dot { - background: var(--mut); -} - -.service-status.status-online { - color: var(--good); -} - -.service-status.status-unreachable { - color: var(--bad); -} - -.service-latency { - color: var(--mut); -} - -.service-source, -.service-note { - margin: 8px 0 0; - color: var(--mut); - font-size: .76rem; -} - .status-history { - margin-top: 16px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); } -.client-info > summary, .status-history > summary { + width: fit-content; + color: var(--mut); cursor: pointer; - font-weight: 700; - list-style-position: outside; + font-size: .8rem; + font-weight: 750; +} + +.status-history > summary:hover { + color: var(--fg); } -.client-info[open] > summary, .status-history[open] > summary { margin-bottom: 14px; } -.status-history h3 { - margin: 0 0 10px; - color: var(--mut); - font-size: .95rem; - font-weight: 700; -} - .history-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); - gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; } .history-row { display: grid; - grid-template-columns: minmax(130px, 1fr) minmax(150px, 1.2fr) auto; - gap: 10px; + grid-template-columns: minmax(110px, 1fr) minmax(130px, 1.35fr) auto; align-items: center; - padding: 10px; + gap: 10px; + min-width: 0; + padding: 10px 11px; border: 1px solid var(--line); border-radius: 12px; - background: color-mix(in srgb, var(--card) 88%, transparent); + background: rgba(9, 15, 23, .42); } .history-meta { @@ -468,7 +573,8 @@ a:hover { .history-name { overflow: hidden; color: var(--fg); - font-weight: 700; + font-size: .82rem; + font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } @@ -476,20 +582,16 @@ a:hover { .history-summary, .history-avg { color: var(--mut); - font-size: .82rem; - font-weight: 600; + font-size: .7rem; + font-weight: 700; } .history-summary.is-stable { color: var(--good); } -.history-summary.is-collecting { - color: var(--mut); -} - .history-summary.is-recovering { - color: #f59e0b; + color: var(--warn); } .history-summary.is-issues { @@ -498,15 +600,15 @@ a:hover { .history-line { display: grid; - grid-template-columns: repeat(24, minmax(3px, 1fr)); - gap: 3px; + grid-template-columns: repeat(24, minmax(2px, 1fr)); + gap: 2px; min-width: 0; } .history-segment { display: block; - height: 18px; - min-width: 3px; + min-width: 2px; + height: 16px; border-radius: 999px; background: var(--line); cursor: default; @@ -530,326 +632,397 @@ a:hover { opacity: .55; } -.dot { - width: 8px; - height: 8px; - border-radius: 50% +.service-section { + position: relative; + isolation: isolate; + min-height: 176px; + padding: 28px; + --service-accent: var(--acc-strong); } -footer { - margin: 26px 0; +.service-section::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: linear-gradient(180deg, transparent, var(--service-accent), transparent); + opacity: .9; +} + +.service-section::after { + content: ""; + position: absolute; + z-index: -1; + top: -120px; + left: -80px; + width: 360px; + height: 300px; + border-radius: 50%; + background: color-mix(in srgb, var(--service-accent) 12%, transparent); + filter: blur(48px); + pointer-events: none; +} + +#section-jf { + --service-accent: #8b5cf6; +} + +#section-fb { + --service-accent: #14b8a6; +} + +#section-ab { + --service-accent: #f59e0b; +} + +#section-st { + --service-accent: #3b82f6; +} + +#section-muse { + --service-accent: #ec4899; +} + +.j-wrap { display: flex; - justify-content: center; align-items: center; - gap: 8px; - color: var(--mut); - font-size: .92rem; + justify-content: space-between; + gap: 24px; + min-height: 118px; } -@media (prefers-reduced-motion: reduce) { +.j-left { + display: flex; + align-items: center; + gap: 22px; + min-width: 0; +} - .card, - .btn { - transition: none - } +.j-left > div:last-child { + min-width: 0; } -/* Light theme */ -.theme-light { - --bg: #f7f9fc; - --fg: #0b1220; - --mut: #4b5563; - --line: #d9e1ec; - --card: #ffffff; - --acc: #0e88da; - --ring: 0 0 0 2px #0ea5e955; - --good: #16a34a; - --bad: #dc2626; +.j-icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 88px; + height: 88px; + padding: 13px; + border: 1px solid color-mix(in srgb, var(--service-accent) 26%, var(--line)); + border-radius: 24px; + background: color-mix(in srgb, var(--service-accent) 10%, rgba(9, 15, 23, .76)); + box-shadow: inset 0 1px rgba(255, 255, 255, .05), 0 16px 36px rgba(0, 0, 0, .18); } -.theme-light body { - background: radial-gradient(1400px 800px at 10% -10%, #eef3f9 0, #f7f9fc 45%) fixed +.j-icon img, +.j-icon svg { + display: block; + width: 62px; + height: 62px; + object-fit: contain; } -.theme-light .btn { - background: linear-gradient(180deg, #ffffff, #eef3f9); - border-color: #d9e1ec; - color: #0b1220 +.service-section h2 { + margin-bottom: 5px; + font-size: 1.55rem; } -.theme-light .tab, -.theme-light .chip, -.theme-light .pill { - background: linear-gradient(180deg, #ffffff, #eef3f9); - border-color: #d9e1ec; - color: #0b1220 +.service-section .tag { + font-size: .92rem; } -.theme-light body { - background: radial-gradient(1400px 800px at 10% -10%, #ffffff 0, #f2f5fa 60%) fixed +.service-meta { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 13px !important; } -.theme-light .tab, -.theme-light .chip, -.theme-light .btn, -.theme-light .pill { - background: linear-gradient(180deg, #f9fafb, #f3f4f6); - color: var(--fg) +.service-status.status-online { + border-color: rgba(49, 212, 119, .2); + color: var(--good); + background: rgba(49, 212, 119, .07); } -.theme-light .card { - background: var(--card) +.service-status.status-unreachable { + border-color: rgba(255, 94, 104, .22); + color: var(--bad); + background: rgba(255, 94, 104, .07); } -.theme-light .history-row { - background: #f9fafb; +.service-latency { + color: var(--mut); } -.theme-light .history-summary.is-recovering { - color: #d97706; +.service-source, +.service-note { + margin: 8px 0 0; + color: var(--mut); + font-size: .72rem; } -.theme-light .topbar { - background: rgba(255, 255, 255, 0.86); - border-bottom: 1px solid var(--line); - backdrop-filter: saturate(140%) blur(6px); +.service-note { + color: color-mix(in srgb, var(--service-accent) 55%, var(--mut)); + font-weight: 700; } -.theme-light .tag { - color: #374151; +.service-actions { + flex: 0 0 auto; } -/* OLED theme */ -.theme-oled { - --bg: #000000; - --fg: #f4f7ff; - --mut: #9aa7ba; - --line: #121212; - --card: #000000; - --acc: #6db5ff; - --ring: 0 0 0 2px #6db5ff55; - --good: #22c55e; - --bad: #ef4444; - --shadow: 0 12px 30px rgba(0, 0, 0, .6); +a { + color: var(--acc); + text-decoration: none; + font-weight: 650; } -.theme-oled body { - background: #000 fixed; +a:hover { + text-decoration: underline; } -.theme-oled .topbar { - background: rgba(0, 0, 0, 0.92); - border-bottom: 1px solid var(--line); +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 46px; + padding: 11px 17px; + border: 1px solid color-mix(in srgb, var(--service-accent) 50%, transparent); + border-radius: 14px; + color: #fff; + background: linear-gradient(135deg, color-mix(in srgb, var(--service-accent) 82%, #0a2237), var(--service-accent)); + box-shadow: 0 12px 28px color-mix(in srgb, var(--service-accent) 18%, transparent); + font-weight: 800; + text-decoration: none; + transition: transform .16s ease, filter .16s ease, box-shadow .16s ease; } -.theme-oled .card, -.theme-oled .btn, -.theme-oled .tab, -.theme-oled .chip, -.theme-oled .pill { - background: #000; - border-color: var(--line); - color: var(--fg); +.btn svg path { + stroke: currentColor; } -.theme-oled .tab:hover, -.theme-oled .btn:hover, -.theme-oled .chip:hover { - background: #050505; +.btn:hover { + filter: brightness(1.08); + text-decoration: none; + transform: translateY(-2px); } -.theme-oled .history-row { - background: #050505; +.btn:focus-visible { + outline: 0; + box-shadow: var(--ring), 0 12px 28px color-mix(in srgb, var(--service-accent) 18%, transparent); } -/* ---- Theme toggle animations ---- */ +.card.active-card { + border-color: color-mix(in srgb, var(--service-accent) 32%, var(--line)); +} -#theme-toggle .emoji { - display: inline-block; - transition: transform .3s ease; +footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 26px 0; + color: var(--mut); + font-size: .82rem; } -#theme-toggle .emoji.flip { - transform: rotateY(180deg) scale(1.08); +.github-link { + display: inline-flex; + align-items: center; + gap: 6px; + vertical-align: middle; } -#theme-toggle { - transition: transform 0.6s ease; +/* Light theme */ +.theme-light { + color-scheme: light; + --bg: #f3f7fb; + --fg: #132033; + --mut: #5c6f84; + --line: rgba(77, 106, 133, .18); + --card: #fff; + --surface: #f6f9fc; + --surface-strong: #eef4f9; + --acc: #087ec5; + --acc-strong: #087ec5; + --good: #158f4b; + --bad: #d33c48; + --shadow: 0 18px 48px rgba(49, 73, 97, .12); } -#theme-toggle:active { - transform: rotate(360deg); +.theme-light body { + background: + radial-gradient(circle at 12% -8%, rgba(14, 165, 233, .14), transparent 34rem), + radial-gradient(circle at 92% 18%, rgba(99, 102, 241, .07), transparent 28rem), + linear-gradient(180deg, #fbfdff 0, var(--bg) 42rem) fixed; } -html.theme-light #theme-toggle { - border: none !important; - outline: none !important; - box-shadow: none !important; +.theme-light .topbar { + background: rgba(250, 252, 255, .86); } -/* ---- Language switcher ---- */ -.lang-switch { - position: relative; - display: inline-flex; - gap: 8px; +.theme-light nav.tabs, +.theme-light .client-info { + background: rgba(255, 255, 255, .72); } -#lang-en, -#lang-pl { - position: relative; - z-index: 1; +.theme-light .card { + background: linear-gradient(145deg, rgba(255, 255, 255, .98), rgba(246, 249, 252, .98)); } -.lang-switch::before { - content: ''; - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: calc(50% - 4px); - border-radius: 999px; - border: 2px solid var(--acc); - pointer-events: none; - transition: left 0.3s cubic-bezier(.4, 0, .2, 1); - box-sizing: border-box; +.theme-light .chip, +.theme-light .pill, +.theme-light .client-metric { + background: rgba(246, 249, 252, .94); } -.lang-switch:has(#lang-pl[aria-pressed="true"])::before { - left: calc(50% + 4px); +.theme-light .tab[aria-selected="true"] { + color: #075985; + background: linear-gradient(135deg, rgba(14, 165, 233, .18), rgba(59, 130, 246, .1)); } -.lang-switch:has(#lang-en[aria-pressed="true"])::before { - left: 0; +.theme-light .dashboard-service, +.theme-light .history-row { + background: rgba(246, 249, 252, .88); } -/* Fallback for browsers without :has() support */ -.lang-switch.is-pl::before { - left: calc(50% + 4px); +/* OLED theme */ +.theme-oled { + --bg: #000; + --fg: #f4f7ff; + --mut: #94a1b2; + --line: rgba(255, 255, 255, .1); + --card: #000; + --surface: #050505; + --surface-strong: #090909; + --acc: #6db5ff; + --acc-strong: #258ee8; + --shadow: 0 18px 48px rgba(0, 0, 0, .72); } -.lang-switch.is-en::before { - left: 0; +.theme-oled body { + background: #000 fixed; } -.card.active-card { - outline: 3px solid var(--acc); - outline-offset: 4px; - border-radius: 1rem +.theme-oled .topbar, +.theme-oled nav.tabs, +.theme-oled .client-info, +.theme-oled .card, +.theme-oled .chip, +.theme-oled .pill, +.theme-oled .client-metric, +.theme-oled .dashboard-service, +.theme-oled .history-row { + background: #000; } -.github-link { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--acc); - font-weight: 600; - vertical-align: middle; +/* Toggle animation */ +#theme-toggle .emoji { + display: inline-block; + transition: transform .3s ease; } -.github-link:hover { - text-decoration: underline; +#theme-toggle .emoji.flip { + transform: rotateY(180deg) scale(1.08); } -@media (max-width: 900px) { - .controls { - flex: 1 1 100%; - width: 100%; +@media (max-width: 980px) { + .topbar-lower { + align-items: stretch; + flex-direction: column; } - .pc-status { - width: 100%; - white-space: normal; - flex-wrap: wrap; - row-gap: 6px; + .client-info { + justify-content: space-between; } - .pc-status .pc-os { - max-width: 100%; - overflow-wrap: anywhere; + .dashboard-services { + grid-template-columns: repeat(3, minmax(0, 1fr)); } } -/* ---- Mobile-only layout fixes (desktop stays unchanged) ---- */ -@media (max-width: 768px) { +@media (max-width: 720px) { + .topbar { + position: relative; + } + + .topbar-inner, .wrap { - padding: 16px; + padding-inline: 16px; } .topbar-inner { - padding: 10px 16px; - gap: 12px; - align-items: stretch; + padding-block: 12px; } - .brand { - min-width: 0; - flex: 1 1 100%; + .topbar-primary { align-items: flex-start; + flex-direction: column; + gap: 12px; } - nav.tabs { - flex: 1 1 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - padding: 4px; + .controls { + width: 100%; } - .tab[aria-selected="true"]::after { - top: 0; - right: 0; - bottom: 0; - left: 0; + #theme-toggle { + flex: 1 1 auto; } - .controls { - gap: 10px; + .client-info { + display: grid; + justify-content: stretch; + gap: 7px; + width: 100%; + padding: 8px; } - .chip, - .tab { - padding: 10px 12px; - min-height: 44px; + .client-heading { + padding: 0 3px; } - .card { - padding: 16px; + .pc-status { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; } - .j-wrap { - flex-direction: column; - align-items: stretch; - gap: 14px; + .client-metric { + display: grid; + gap: 1px; + padding: 7px 8px; } - .j-left { - align-items: flex-start; + .client-value { + font-size: .76rem; } - .j-icon svg, - .j-icon img { - width: 56px; - height: 56px; + nav.tabs { + width: 100%; } - .btn { - width: 100%; - justify-content: center; - min-height: 44px; + .tab, + .chip { + min-height: 42px; } - footer { - flex-wrap: wrap; - text-align: center; - row-gap: 4px; + .dashboard-top { + flex-direction: column; + gap: 13px; } - .dashboard-grid .pill { + .dashboard-grid { + justify-content: flex-start; width: 100%; - justify-content: center; } .dashboard-services { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-service { + padding: 11px; } .history-grid { @@ -863,14 +1036,65 @@ html.theme-light #theme-toggle { .history-avg { justify-self: start; } + + .card, + .dashboard-card, + .service-section { + padding: 18px; + border-radius: 18px; + } + + .j-wrap { + align-items: stretch; + flex-direction: column; + gap: 20px; + } + + .j-left { + align-items: flex-start; + gap: 15px; + } + + .j-icon { + width: 68px; + height: 68px; + padding: 10px; + border-radius: 19px; + } + + .j-icon img, + .j-icon svg { + width: 48px; + height: 48px; + } + + .service-section h2 { + font-size: 1.3rem; + } + + .service-actions, + .btn { + width: 100%; + } + + footer { + flex-wrap: wrap; + row-gap: 4px; + text-align: center; + } +} + +@media (max-width: 390px) { + .client-value { + font-size: .7rem; + } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { - animation: none !important; - transition: none !important; scroll-behavior: auto !important; + transition: none !important; } } diff --git a/index.html b/index.html index d4f645c..3ecb7b2 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ adiker.eu — Homepage - + @@ -23,45 +23,60 @@
-
- -
-

adiker.eu

-
My Playground
-
Usually available: 9:00 AM – 1:00 AM CEST
+
+
+ +
+

adiker.eu

+
My Playground
+
Usually available: 9:00 AM – 1:00 AM CEST
+
+
+
+ +
+
+ + +
+
- -
- -
-
- - +
+ +
+
+ + This device
+
Detecting client…
- - + + diff --git a/js/app.js b/js/app.js index 438618a..b6e6c79 100644 --- a/js/app.js +++ b/js/app.js @@ -48,7 +48,7 @@ const STR = { sections: 'Sections', services: 'Service details', skipLink: 'Skip to main content', - clientInfo: 'Client information', + clientInfo: 'This device', latency: 'Latency', source: 'Measured from this browser', dark: 'Dark', @@ -56,8 +56,8 @@ const STR = { oled: 'OLED' }, dashboard: { - title: '📊 Service dashboard', - sub: 'Live status overview from this browser', + title: 'Service status', + sub: 'Live measurements from this browser', overall: 'online', checking: 'Checking…', unreachable: 'unreachable', @@ -79,9 +79,9 @@ const STR = { status: { checking: 'Checking…', online: 'Online', unreachable: 'Unreachable', unknown: 'Unknown' }, statusSource: 'Status measured from this browser', pc: { - osPrefix: 'Your OS', - browserPrefix: 'Your browser', - devicePrefix: 'Your device', + osPrefix: 'OS', + browserPrefix: 'Browser', + devicePrefix: 'Device', os: { windows: 'Windows', android: 'Android', @@ -132,7 +132,7 @@ const STR = { sections: 'Sekcje', services: 'Szczegóły usług', skipLink: 'Przejdź do treści głównej', - clientInfo: 'Informacje o kliencie', + clientInfo: 'To urządzenie', latency: 'Opóźnienie', source: 'Pomiar z tej przeglądarki', dark: 'Ciemny', @@ -140,7 +140,7 @@ const STR = { oled: 'OLED' }, dashboard: { - title: '📊 Panel usług', + title: 'Status usług', sub: 'Bieżące pomiary z tej przeglądarki', overall: 'online', checking: 'Sprawdzanie…', @@ -163,9 +163,9 @@ const STR = { status: { checking: 'Sprawdzanie…', online: 'Online', unreachable: 'Brak odpowiedzi', unknown: 'Nieznany' }, statusSource: 'Status mierzony z tej przeglądarki', pc: { - osPrefix: 'Twój OS', - browserPrefix: 'Twoja przeglądarka', - devicePrefix: 'Twoje urządzenie', + osPrefix: 'OS', + browserPrefix: 'Przeglądarka', + devicePrefix: 'Urządzenie', os: { windows: 'Windows', android: 'Android', @@ -462,11 +462,16 @@ function renderPcStatus(lang = getLang()) { const browserLabel = L.pc.browser[DETECTED_BROWSER] || L.pc.browser.other; const deviceLabel = L.pc.device[DETECTED_DEVICE] || L.pc.device.other; pcStatus.innerHTML = ` - ${L.pc.osPrefix}: ${osLabel} - ${L.pc.browserPrefix}: ${browserLabel} - ${L.pc.devicePrefix}: ${deviceLabel} + + ${L.pc.osPrefix}${osLabel} + + + ${L.pc.browserPrefix}${browserLabel} + + + ${L.pc.devicePrefix}${deviceLabel} + `; - } function renderDashboardService(key, lang, L) { @@ -525,19 +530,19 @@ function applyLang(lang) { document.documentElement.lang = lang; document.title = `adiker.eu — ${L.documentTitle}`; setText('skip-link', L.ui.skipLink); - setText('jf-title', '🎬 ' + L.jf.title); + setText('jf-title', L.jf.title); setText('jf-sub', L.jf.sub); setText('jf-btn-text', L.jf.open); - setText('fb-title', '📁 ' + L.fb.title); + setText('fb-title', L.fb.title); setText('fb-sub', L.fb.sub); setText('fb-btn-text', L.fb.open); - setText('ab-title', '⚡ ' + L.ab.title); + setText('ab-title', L.ab.title); setText('ab-sub', L.ab.sub); setText('ab-btn-text', L.ab.open); - setText('st-title', '🚀 ' + L.st.title); + setText('st-title', L.st.title); setText('st-sub', L.st.sub); setText('st-btn-text', L.st.open); - setText('mu-title', '🎧 ' + L.mu.title); + setText('mu-title', L.mu.title); setText('mu-sub', L.mu.sub); setText('mu-note', L.mu.note); @@ -594,7 +599,7 @@ function setTheme(mode) { const themeColor = normalizedMode === 'light' ? '#f7f9fc' - : (normalizedMode === 'oled' ? '#000000' : '#0b0f14'); + : (normalizedMode === 'oled' ? '#000000' : '#080d14'); const themeColorMeta = document.querySelector('meta[name="theme-color"]'); if (themeColorMeta) themeColorMeta.setAttribute('content', themeColor); From fa455bf14931c6539d02f7190be5172508f36455 Mon Sep 17 00:00:00 2001 From: Adiker Date: Thu, 17 Sep 2026 19:02:02 +0200 Subject: [PATCH 11/12] fix(ui): prevent header content clipping --- css/styles.css | 25 ++++++++++++++++++++++--- index.html | 2 +- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/css/styles.css b/css/styles.css index 4fb01f4..32d7e9f 100644 --- a/css/styles.css +++ b/css/styles.css @@ -348,11 +348,11 @@ nav.tabs::-webkit-scrollbar { } .client-value { - overflow: hidden; color: var(--fg); font-size: .72rem; font-weight: 800; - text-overflow: ellipsis; + overflow-wrap: anywhere; + white-space: normal; } .tab:focus-visible, @@ -927,7 +927,7 @@ footer { transform: rotateY(180deg) scale(1.08); } -@media (max-width: 980px) { +@media (max-width: 1120px) { .topbar-lower { align-items: stretch; flex-direction: column; @@ -936,7 +936,9 @@ footer { .client-info { justify-content: space-between; } +} +@media (max-width: 980px) { .dashboard-services { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -999,7 +1001,24 @@ footer { } nav.tabs { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); width: 100%; + overflow: visible; + } + + .tab { + min-width: 0; + padding-inline: 8px; + text-align: center; + } + + .tab:nth-child(-n + 3) { + grid-column: span 2; + } + + .tab:nth-child(n + 4) { + grid-column: span 3; } .tab, diff --git a/index.html b/index.html index 3ecb7b2..fe77539 100644 --- a/index.html +++ b/index.html @@ -324,7 +324,7 @@

Muse

- + From dd3d953baed7a26a7e68a807eb7f12eee7f3cc39 Mon Sep 17 00:00:00 2001 From: Adiker Date: Thu, 17 Sep 2026 19:08:25 +0200 Subject: [PATCH 12/12] fix(ui): keep service tabs fully visible --- css/styles.css | 38 ++++++++++++++++++++++++++++---------- index.html | 19 ++++++++++++------- js/app.js | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/css/styles.css b/css/styles.css index 32d7e9f..9f54ac6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -65,12 +65,16 @@ summary { transform: translateY(0); } -.wrap, -.topbar-inner { +.wrap { width: min(100%, 1080px); margin-inline: auto; } +.topbar-inner { + width: min(100%, 1200px); + margin-inline: auto; +} + .wrap { padding: 26px 28px 10px; } @@ -278,6 +282,10 @@ nav.tabs::-webkit-scrollbar { transition: color .16s ease, background .16s ease, transform .16s ease; } +.tab-label-compact { + display: none; +} + .tab:hover { color: var(--fg); background: rgba(103, 189, 255, .08); @@ -351,8 +359,7 @@ nav.tabs::-webkit-scrollbar { color: var(--fg); font-size: .72rem; font-weight: 800; - overflow-wrap: anywhere; - white-space: normal; + white-space: nowrap; } .tab:focus-visible, @@ -1002,23 +1009,24 @@ footer { nav.tabs { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); width: 100%; overflow: visible; } .tab { min-width: 0; - padding-inline: 8px; + padding-inline: 3px; + font-size: .69rem; text-align: center; } - .tab:nth-child(-n + 3) { - grid-column: span 2; + .tab-label-full { + display: none; } - .tab:nth-child(n + 4) { - grid-column: span 3; + .tab-label-compact { + display: inline; } .tab, @@ -1104,9 +1112,19 @@ footer { } @media (max-width: 390px) { + .client-key { + font-size: .54rem; + letter-spacing: .02em; + } + .client-value { font-size: .7rem; } + + .tab { + font-size: .64rem; + letter-spacing: -.01em; + } } @media (prefers-reduced-motion: reduce) { diff --git a/index.html b/index.html index fe77539..b4a3bec 100644 --- a/index.html +++ b/index.html @@ -45,15 +45,20 @@

adiker.eu

@@ -324,8 +329,8 @@

Muse

- - + + diff --git a/js/app.js b/js/app.js index b6e6c79..43aa2e8 100644 --- a/js/app.js +++ b/js/app.js @@ -39,6 +39,13 @@ const STR = { openspeedtest: 'OpenSpeedTest', muse: 'Muse' }, + navCompact: { + jellyfin: 'Jellyfin', + filebrowser: 'Files', + autobrr: 'autobrr', + openspeedtest: 'Speed', + muse: 'Muse' + }, subtitle: 'My Playground', availability: 'Usually available: 9:00 AM – 1:00 AM CEST', ui: { @@ -123,6 +130,13 @@ const STR = { openspeedtest: 'OpenSpeedTest', muse: 'Muse' }, + navCompact: { + jellyfin: 'Jellyfin', + filebrowser: 'Pliki', + autobrr: 'autobrr', + openspeedtest: 'Speed', + muse: 'Muse' + }, subtitle: 'Mój plac zabaw', availability: 'Zwykle dostępny: 09:00 – 01:00 CEST', ui: { @@ -290,6 +304,17 @@ function setText(id, value) { if (el) el.textContent = value; } +function setTabLabel(id, fullLabel, compactLabel) { + const tab = document.getElementById(id); + if (!tab) return; + + const full = tab.querySelector('.tab-label-full'); + const compact = tab.querySelector('.tab-label-compact'); + if (full) full.textContent = fullLabel; + if (compact) compact.textContent = compactLabel; + tab.setAttribute('aria-label', fullLabel); +} + function getServiceLabel(key, lang = getLang()) { const L = STR[lang] || STR.en; if (L[key] && L[key].short) return L[key].short; @@ -560,11 +585,11 @@ function applyLang(lang) { if (abBtn) abBtn.setAttribute('aria-label', L.ab.open); if (stBtn) stBtn.setAttribute('aria-label', L.st.open); - setText('tab-jellyfin', L.nav.jellyfin); - setText('tab-filebrowser', L.nav.filebrowser); - setText('tab-autobrr', L.nav.autobrr); - setText('tab-speedtest', L.nav.openspeedtest); - setText('tab-muse', L.nav.muse); + setTabLabel('tab-jellyfin', L.nav.jellyfin, L.navCompact.jellyfin); + setTabLabel('tab-filebrowser', L.nav.filebrowser, L.navCompact.filebrowser); + setTabLabel('tab-autobrr', L.nav.autobrr, L.navCompact.autobrr); + setTabLabel('tab-speedtest', L.nav.openspeedtest, L.navCompact.openspeedtest); + setTabLabel('tab-muse', L.nav.muse, L.navCompact.muse); const tabsNav = document.querySelector('nav.tabs'); if (tabsNav) tabsNav.setAttribute('aria-label', L.ui.sections);