diff --git a/README.md b/README.md index f77b4ab..1cb36db 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 +- 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 +- 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 +- Always-visible client context in the header 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 +- 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 +- 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 +- 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 4689ca5..9f54ac6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,163 +1,269 @@ :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 { - max-width: 980px; - margin: 0 auto; - padding: 28px + width: min(100%, 1080px); + margin-inline: auto; +} + +.topbar-inner { + width: min(100%, 1200px); + margin-inline: auto; +} + +.wrap { + 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: 0; - display: inline-flex; +#subtitle { + display: inline; + font-size: .88rem; +} + +#availability { + 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); - font-weight: 600; - font-size: .92rem; + gap: 7px; + margin-top: 2px; + font-size: .76rem; } -.pc-status .pc-main { - font-weight: 700; +#availability::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 0 4px rgba(49, 212, 119, .1); } -.pc-status .pc-os { - display: inline-flex; +.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; } -.pc-status.is-on .pc-main { - color: var(--good); +.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; } -.pc-status.is-off .pc-main { - color: var(--bad); +.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 { @@ -165,202 +271,305 @@ 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-label-compact { + display: none; } .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; +} + +.client-key { + color: var(--mut); + font-size: .64rem; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; } -.chip[aria-pressed="true"], -.chip.active { - outline: 2px solid var(--acc) +.client-value { + color: var(--fg); + font-size: .72rem; + font-weight: 800; + white-space: nowrap; } .tab:focus-visible, .chip:focus-visible, -a:focus-visible { +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) +.dashboard-grid .pill { + color: var(--mut); + font-size: .78rem; } -.btn:focus-visible { - outline: none; - box-shadow: var(--ring) +.dashboard-grid #dash-overall { + color: var(--fg); } -.j-wrap { - display: flex; - gap: 18px; - align-items: center; - justify-content: space-between; - flex-wrap: wrap +.dashboard-grid #dash-overall.is-checking { + color: var(--mut); } -.j-left { - display: flex; - gap: 16px; - align-items: center +.dashboard-grid #dash-overall.is-unreachable { + color: var(--bad); } -.j-icon { - display: flex; +.dashboard-services { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-top: 18px; +} + +.dashboard-service { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + "dot name name" + "state state latency"; align-items: center; - justify-content: center; + gap: 8px 7px; + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + 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; } -.j-icon svg, -.j-icon img { - display: block; - width: 70px; - height: 70px; +.dashboard-service:hover { + transform: translateY(-1px); + border-color: rgba(103, 189, 255, .26); } -.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-service > .dot { + grid-area: dot; } -.dashboard-grid { - display: flex; - flex-wrap: wrap; - gap: 8px; +.dashboard-service-name { + grid-area: name; + min-width: 0; + overflow: hidden; + 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: .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, +.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); + box-shadow: 0 0 0 4px rgba(255, 94, 104, .1); +} + +.dashboard-service.status-online .dashboard-service-state { + color: var(--good); +} + +.dashboard-service.status-unreachable .dashboard-service-state { + color: var(--bad); } .status-history { - margin-top: 16px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); } -.status-history h3 { - margin: 0 0 10px; +.status-history > summary { + width: fit-content; color: var(--mut); - font-size: .95rem; - font-weight: 700; + cursor: pointer; + font-size: .8rem; + font-weight: 750; +} + +.status-history > summary:hover { + color: var(--fg); +} + +.status-history[open] > summary { + margin-bottom: 14px; } .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 { @@ -371,7 +580,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; } @@ -379,8 +589,8 @@ 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 { @@ -388,7 +598,7 @@ a:hover { } .history-summary.is-recovering { - color: #f59e0b; + color: var(--warn); } .history-summary.is-issues { @@ -397,15 +607,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; @@ -413,7 +623,7 @@ a:hover { } .history-segment.is-online:hover, -.history-segment.is-offline:hover { +.history-segment.is-unreachable:hover { transform: scaleY(1.3); } @@ -421,7 +631,7 @@ a:hover { background: var(--good); } -.history-segment.is-offline { +.history-segment.is-unreachable { background: var(--bad); } @@ -429,361 +639,491 @@ 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, -.theme-light .pc-status { - 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, -.theme-light .pc-status { - 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; } -.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; +.service-note { + color: color-mix(in srgb, var(--service-accent) 55%, var(--mut)); + font-weight: 700; } -.toast.show { - opacity: 1; - transform: translate(-50%, 0) +.service-actions { + flex: 0 0 auto; } -.theme-light .topbar { - background: rgba(255, 255, 255, 0.86); - border-bottom: 1px solid var(--line); - backdrop-filter: saturate(140%) blur(6px); +a { + color: var(--acc); + text-decoration: none; + font-weight: 650; } -.theme-light .tag { - color: #374151; +a:hover { + text-decoration: underline; } -/* 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); +.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 body { - background: #000 fixed; +.btn svg path { + stroke: currentColor; } -.theme-oled .topbar { - background: rgba(0, 0, 0, 0.92); - border-bottom: 1px solid var(--line); +.btn:hover { + filter: brightness(1.08); + text-decoration: none; + transform: translateY(-2px); } -.theme-oled .card, -.theme-oled .btn, -.theme-oled .tab, -.theme-oled .chip, -.theme-oled .pill, -.theme-oled .pc-status, -.theme-oled .toast { - background: #000; - border-color: var(--line); - color: var(--fg); +.btn:focus-visible { + outline: 0; + box-shadow: var(--ring), 0 12px 28px color-mix(in srgb, var(--service-accent) 18%, transparent); } -.theme-oled .tab:hover, -.theme-oled .btn:hover, -.theme-oled .chip:hover { - background: #050505; +.card.active-card { + border-color: color-mix(in srgb, var(--service-accent) 32%, var(--line)); } -.theme-oled .history-row { - background: #050505; +footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 26px 0; + color: var(--mut); + font-size: .82rem; } -/* ---- Theme toggle animations ---- */ +.github-link { + display: inline-flex; + align-items: center; + gap: 6px; + vertical-align: middle; +} -#theme-toggle .emoji { - display: inline-block; - transition: transform .3s 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 .emoji.flip { - transform: rotateY(180deg) scale(1.08); +.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; } -#theme-toggle { - transition: transform 0.6s ease; +.theme-light .topbar { + background: rgba(250, 252, 255, .86); } -#theme-toggle:active { - transform: rotate(360deg); +.theme-light nav.tabs, +.theme-light .client-info { + background: rgba(255, 255, 255, .72); } -html.theme-light #theme-toggle { - border: none !important; - outline: none !important; - box-shadow: none !important; +.theme-light .card { + background: linear-gradient(145deg, rgba(255, 255, 255, .98), rgba(246, 249, 252, .98)); } -/* ---- Language switcher ---- */ -.lang-switch { - position: relative; - display: inline-flex; - gap: 8px; +.theme-light .chip, +.theme-light .pill, +.theme-light .client-metric { + background: rgba(246, 249, 252, .94); } -#lang-en, -#lang-pl { - position: relative; - z-index: 1; +.theme-light .tab[aria-selected="true"] { + color: #075985; + background: linear-gradient(135deg, rgba(14, 165, 233, .18), rgba(59, 130, 246, .1)); } -.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 .dashboard-service, +.theme-light .history-row { + background: rgba(246, 249, 252, .88); } -.lang-switch:has(#lang-pl[aria-pressed="true"])::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:has(#lang-en[aria-pressed="true"])::before { - left: 0; +.theme-oled body { + background: #000 fixed; } -/* Fallback for browsers without :has() support */ -.lang-switch.is-pl::before { - left: calc(50% + 4px); +.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; } -.lang-switch.is-en::before { - left: 0; +/* Toggle animation */ +#theme-toggle .emoji { + display: inline-block; + transition: transform .3s ease; } -.card.active-card { - outline: 3px solid var(--acc); - outline-offset: 4px; - border-radius: 1rem +#theme-toggle .emoji.flip { + transform: rotateY(180deg) scale(1.08); } -.github-link { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--acc); - font-weight: 600; - vertical-align: middle; +@media (max-width: 1120px) { + .topbar-lower { + align-items: stretch; + flex-direction: column; + } + + .client-info { + justify-content: space-between; + } } -.github-link:hover { - text-decoration: underline; +@media (max-width: 980px) { + .dashboard-services { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } -@media (max-width: 900px) { +@media (max-width: 720px) { + .topbar { + position: relative; + } + + .topbar-inner, + .wrap { + padding-inline: 16px; + } + + .topbar-inner { + padding-block: 12px; + } + + .topbar-primary { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .controls { - flex: 1 1 100%; width: 100%; } + #theme-toggle { + flex: 1 1 auto; + } + + .client-info { + display: grid; + justify-content: stretch; + gap: 7px; + width: 100%; + padding: 8px; + } + + .client-heading { + padding: 0 3px; + } + .pc-status { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); 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; + .client-metric { + display: grid; + gap: 1px; + padding: 7px 8px; } -} -/* ---- Mobile-only layout fixes (desktop stays unchanged) ---- */ -@media (max-width: 768px) { - .wrap { - padding: 16px; + .client-value { + font-size: .76rem; } - .topbar-inner { - padding: 10px 16px; - gap: 12px; - align-items: stretch; + nav.tabs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; + overflow: visible; } - .brand { + .tab { min-width: 0; - flex: 1 1 100%; - align-items: flex-start; + padding-inline: 3px; + font-size: .69rem; + text-align: center; } - nav.tabs { - flex: 1 1 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - padding: 4px; + .tab-label-full { + display: none; } - .tab[aria-selected="true"]::after { - top: 0; - right: 0; - bottom: 0; - left: 0; + .tab-label-compact { + display: inline; } - .controls { - gap: 10px; + .tab, + .chip { + min-height: 42px; } - .chip, - .tab { - padding: 10px 12px; - min-height: 44px; + .dashboard-top { + flex-direction: column; + gap: 13px; + } + + .dashboard-grid { + justify-content: flex-start; + width: 100%; + } + + .dashboard-services { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-service { + padding: 11px; + } + + .history-grid { + grid-template-columns: 1fr; + } + + .history-row { + grid-template-columns: 1fr; + } + + .history-avg { + justify-self: start; } - .card { - padding: 16px; + .card, + .dashboard-card, + .service-section { + padding: 18px; + border-radius: 18px; } .j-wrap { - flex-direction: column; align-items: stretch; - gap: 14px; + 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; } - .j-icon svg, - .j-icon img { - width: 56px; - height: 56px; + .service-section h2 { + font-size: 1.3rem; } + .service-actions, .btn { width: 100%; - justify-content: center; - min-height: 44px; } footer { flex-wrap: wrap; - text-align: center; row-gap: 4px; + text-align: center; } +} - .dashboard-grid .pill { - width: 100%; - justify-content: center; - } - - .history-grid { - grid-template-columns: 1fr; +@media (max-width: 390px) { + .client-key { + font-size: .54rem; + letter-spacing: .02em; } - .history-row { - grid-template-columns: 1fr; + .client-value { + font-size: .7rem; } - .history-avg { - justify-self: start; + .tab { + font-size: .64rem; + letter-spacing: -.01em; } } @@ -791,8 +1131,7 @@ html.theme-light #theme-toggle { *, *::before, *::after { - animation: none !important; - transition: none !important; scroll-behavior: auto !important; + transition: none !important; } } diff --git a/index.html b/index.html index 531f991..b4a3bec 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ adiker.eu — Homepage - + @@ -20,58 +20,103 @@ - +
-
- -
-

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…
-
My PC is off :(
-
-

📊 Service dashboard

-

Live status overview

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

Service status

+

Live measurements from this browser

+
+
+ 0/5 online + Last update: -- +
-
-

Recent checks

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

Recent checks

Avg: -- ms
-
+
@@ -130,15 +175,18 @@

Recent checks

src="https://upload.wikimedia.org/wikipedia/commons/4/41/Jellyfin_-_icon-transparent.svg" />
-

🎬 Jellyfin

+

Jellyfin

Your media server

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

Measured from this browser

-
+
-

📁 FileBrowser Quantum

+

FileBrowser Quantum

Your file manager

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

Measured from this browser

-
-

⚡ autobrr

+

autobrr

Automated torrent management

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

Measured from this browser

-
-
- - - + + diff --git a/js/app.js b/js/app.js index 6c66e4a..43aa2e8 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,20 @@ const SERVICES = [ const STR = { en: { + documentTitle: 'Homepage', nav: { jellyfin: 'Jellyfin', filebrowser: 'Filebrowser', autobrr: 'Autobrr', openspeedtest: 'OpenSpeedTest', - muse: 'Muse', - more: 'More services (soon)' + 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', @@ -45,19 +53,27 @@ const STR = { themeTitle: 'Toggle theme', language: 'Language', sections: 'Sections', + services: 'Service details', + skipLink: 'Skip to main content', + clientInfo: 'This device', + latency: 'Latency', + source: 'Measured from this browser', dark: 'Dark', light: 'Light', oled: 'OLED' }, dashboard: { - title: '📊 Service dashboard', - sub: 'Live status overview', + title: 'Service status', + sub: 'Live measurements 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,14 +82,13 @@ 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', + osPrefix: 'OS', + browserPrefix: 'Browser', + devicePrefix: 'Device', os: { windows: 'Windows', android: 'Android', @@ -104,17 +119,23 @@ 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' + }, + 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', @@ -123,19 +144,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: 'To urządzenie', + 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', + 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 (24)', avgLatency: 'Śr.', noData: 'Brak danych', + collecting: 'Zbieranie danych', stable: 'Stabilnie', recovering: 'Wraca do normy', issues: 'Wykryto problemy' @@ -144,14 +173,13 @@ 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', + osPrefix: 'OS', + browserPrefix: 'Przeglądarka', + devicePrefix: 'Urządzenie', os: { windows: 'Windows', android: 'Android', @@ -182,8 +210,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,12 +304,15 @@ 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 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()) { @@ -380,6 +410,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 +429,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 +458,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,78 +473,108 @@ 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} + + ${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; - setText('jf-title', '🎬 ' + L.jf.title); + 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); - 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); 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,15 +585,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); + 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); + 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 +611,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); } @@ -560,7 +624,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); @@ -588,22 +652,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 (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 (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`; +} - SERVICE_STATE[prefix].online = !!ok; - SERVICE_STATE[prefix].latency = Number.isFinite(latency) ? latency : null; - lastCheckAt = Date.now(); - addStatusHistoryEntry(prefix, ok, latency); +function setServiceStatus(prefix, status, latency = null) { + const state = SERVICE_STATE[prefix]; + if (!state) return; + + 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 +701,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 +721,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 +756,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 +785,6 @@ function initTabs() { }); }); - if (moreBtn) { - moreBtn.addEventListener('click', () => { - const L = STR[getLang()] || STR.en; - showToast(L.toastMore); - }); - } - activate('tab-jellyfin'); }