Compare commits
3
Commits
7967279b5b
...
b5e23d8e3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e23d8e3f | ||
|
|
87054e55d9 | ||
|
|
ec96060b2e |
@@ -31,7 +31,7 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to English for unsupported language', () => {
|
it('falls back to English for unsupported language', () => {
|
||||||
expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard');
|
expect(i18n.t('dashboard.title', 'xx')).toBe('Dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to key if not found in any language', () => {
|
it('falls back to key if not found in any language', () => {
|
||||||
@@ -58,8 +58,8 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for unsupported languages', () => {
|
it('returns false for unsupported languages', () => {
|
||||||
expect(i18n.isSupported('zh')).toBe(false);
|
expect(i18n.isSupported('xx')).toBe(false);
|
||||||
expect(i18n.isSupported('ja')).toBe(false);
|
expect(i18n.isSupported('klingon')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,14 +81,61 @@ describe('DC-077: i18n system', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to English for unsupported languages', () => {
|
it('defaults to English for unsupported languages', () => {
|
||||||
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en');
|
||||||
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('klingon-KL,klingon;q=0.9')).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips region codes before matching', () => {
|
it('strips region codes before matching', () => {
|
||||||
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
||||||
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('respects equal q-values by order', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0.5,de;q=0.5')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes q=0 entries per RFC 7231', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves default language when all entries have q=0 (intentional fallback)', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0,fr;q=0')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed q-values gracefully', () => {
|
||||||
|
// 'abc' is not a valid q-value per RFC 7231 grammar, so it is treated as
|
||||||
|
// "no q-value specified" — per the HTTP spec the default weight is q=1.0.
|
||||||
|
expect(i18n.detectLanguage('en;q=abc,fr;q=0.9')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts q=0 boundary (excludes entry)', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts q=1 boundary', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=1,fr;q=0.9')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts q=1.0', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=1.0,fr;q=0.9')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts q=0.001 (lowest non-zero weight)', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0.001,fr;q=0.9')).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts q=0.999', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=0.999,fr;q=0.9')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects q=1.001 (RFC invalid) — defaults to 1.0', () => {
|
||||||
|
expect(i18n.detectLanguage('en;q=1.001,fr;q=0.9')).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles uppercase Q parameter', () => {
|
||||||
|
expect(i18n.detectLanguage('en;Q=0.5,fr;q=0.9')).toBe('fr');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RTL support', () => {
|
describe('RTL support', () => {
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ function createI18nApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DC-077: i18n Routes', () => {
|
describe('DC-077: i18n Routes', () => {
|
||||||
it('GET /i18n/languages returns 5 languages', async () => {
|
it('GET /i18n/languages returns 31 languages', async () => {
|
||||||
const app = createI18nApp();
|
const app = createI18nApp();
|
||||||
const res = await request(app).get('/api/v1/i18n/languages');
|
const res = await request(app).get('/api/v1/i18n/languages');
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.languages).toHaveLength(5);
|
expect(res.body.languages).toHaveLength(31);
|
||||||
expect(res.body.default).toBe('en');
|
expect(res.body.default).toBe('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1764,6 +1764,47 @@ const APP_TEMPLATES = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"vintage-radio": {
|
||||||
|
name: "Vintage Stereo",
|
||||||
|
description: "Glass-front console stereo that tunes curated real internet stations (SomaFM, KEXP, Radio Paradise, and more) through a beautiful analog UI",
|
||||||
|
icon: "📻",
|
||||||
|
category: "Media",
|
||||||
|
popularity: 72,
|
||||||
|
difficulty: "Easy",
|
||||||
|
docker: {
|
||||||
|
image: "nginx:alpine",
|
||||||
|
ports: ["{{PORT}}:80"],
|
||||||
|
volumes: [
|
||||||
|
"/opt/vintage-radio/web:/usr/share/nginx/html:ro"
|
||||||
|
],
|
||||||
|
environment: {}
|
||||||
|
},
|
||||||
|
subdomain: "radio",
|
||||||
|
defaultPort: 8090,
|
||||||
|
healthCheck: "/",
|
||||||
|
subpathSupport: 'none',
|
||||||
|
preInstall: {
|
||||||
|
description: "Materialize the bundled static assets into /opt/vintage-radio/web before starting the container.",
|
||||||
|
script: "vintage-radio-install.sh"
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
"Glass-front console stereo UI with wooden end caps and brushed-metal faceplate",
|
||||||
|
"Tunable analog slide-rule dial with click-stop detents and red cursor flag",
|
||||||
|
"Twin glowing VU meters with smooth needle animation while powered",
|
||||||
|
"Power / Mode / Mute knobs, vertical volume slider, signal-strength LED",
|
||||||
|
"MODE knob filters stations by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||||
|
"18 curated real internet-radio streams (SomaFM, KEXP, Radio Paradise, Space Station Soma, Mission Control, and more)"
|
||||||
|
],
|
||||||
|
setupInstructions: [
|
||||||
|
"Run `bash /usr/local/bin/vintage-radio-install.sh` once before starting the container — copies the bundled web assets (index.html, radio.css, radio.js, stations.json) from the DashCaddy repo (dashcaddy-api/static-sites/vintage-radio/web) into /opt/vintage-radio/web",
|
||||||
|
"Open radio.sami (or your configured subdomain)",
|
||||||
|
"Press the PWR knob, drag the dial or click a station card",
|
||||||
|
"Cycle the MODE knob to filter by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||||
|
"To add stations, edit /opt/vintage-radio/web/stations.json on the host and restart the container"
|
||||||
|
],
|
||||||
|
tags: ["radio", "music", "streaming", "audio", "vintage", "retro", "media"]
|
||||||
|
},
|
||||||
|
|
||||||
"airsonic": {
|
"airsonic": {
|
||||||
name: "Airsonic Advanced",
|
name: "Airsonic Advanced",
|
||||||
description: "Free web-based media streamer",
|
description: "Free web-based media streamer",
|
||||||
|
|||||||
@@ -529,10 +529,40 @@ function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; }
|
|||||||
function detectLanguage(acceptLanguage) {
|
function detectLanguage(acceptLanguage) {
|
||||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||||
var parts = acceptLanguage.split(',');
|
var parts = acceptLanguage.split(',');
|
||||||
|
var entries = [];
|
||||||
for (var i = 0; i < parts.length; i++) {
|
for (var i = 0; i < parts.length; i++) {
|
||||||
var code = parts[i].trim().split(';')[0].split('-')[0].toLowerCase();
|
var seg = parts[i].trim();
|
||||||
if (isSupported(code)) return code;
|
if (!seg) continue;
|
||||||
|
var bits = seg.split(';');
|
||||||
|
var code = bits[0].split('-')[0].trim().toLowerCase();
|
||||||
|
if (!code) continue;
|
||||||
|
var q = 1.0;
|
||||||
|
for (var j = 1; j < bits.length; j++) {
|
||||||
|
var kv = bits[j].trim().split('=');
|
||||||
|
if (kv.length === 2 && kv[0].trim().toLowerCase() === 'q') {
|
||||||
|
var qStr = kv[1].trim();
|
||||||
|
// RFC 7231 §5.3.1: qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3"0" ] )
|
||||||
|
// Match the strict grammar; values that do not conform are treated as
|
||||||
|
// "no q-value specified" and fall back to q=1.0, the HTTP default.
|
||||||
|
var qMatch = qStr.match(/^(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/);
|
||||||
|
if (qMatch) {
|
||||||
|
q = parseFloat(qMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push({ code: code, q: q, order: i });
|
||||||
}
|
}
|
||||||
|
entries.sort(function (a, b) {
|
||||||
|
if (b.q !== a.q) return b.q - a.q;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
for (var k = 0; k < entries.length; k++) {
|
||||||
|
if (entries[k].q === 0) continue;
|
||||||
|
if (isSupported(entries[k].code)) return entries[k].code;
|
||||||
|
}
|
||||||
|
// Intentional design policy: when every supported entry was explicitly
|
||||||
|
// refused with q=0 (or no supported language was offered), fall back to the
|
||||||
|
// server default (DEFAULT_LANGUAGE) rather than honoring the refusal.
|
||||||
return DEFAULT_LANGUAGE;
|
return DEFAULT_LANGUAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-installer.sh — Installs vintage-radio-install.sh into /usr/local/bin.
|
||||||
|
#
|
||||||
|
# Run this once on a host to make `bash /usr/local/bin/vintage-radio-install.sh`
|
||||||
|
# available as a system command. Idempotent.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SRC="${SELF_DIR}/install.sh"
|
||||||
|
DEST="/usr/local/bin/vintage-radio-install.sh"
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC" ]]; then
|
||||||
|
echo "FATAL: $SRC not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0755 "$SRC" "$DEST"
|
||||||
|
echo "Installed: $SRC -> $DEST"
|
||||||
|
echo "Run it with: bash $DEST"
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# vintage-radio-install.sh — Materializes the Vintage Stereo bundled web assets.
|
||||||
|
#
|
||||||
|
# The Vintage Stereo radio template serves its UI through an nginx:alpine
|
||||||
|
# container that mounts /opt/vintage-radio/web as /usr/share/nginx/html. This
|
||||||
|
# script copies the assets (index.html, radio.css, radio.js, stations.json)
|
||||||
|
# from the DashCaddy source tree into that mount target.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash /usr/local/bin/vintage-radio-install.sh
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# DASHCADDY_ROOT — Path to the DashCaddy install root (defaults to /opt/dashcaddy).
|
||||||
|
# TARGET_DIR — Mount target directory (defaults to /opt/vintage-radio/web).
|
||||||
|
#
|
||||||
|
# Idempotent: safe to re-run; overwrites the target files each time.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DASHCADDY_ROOT="${DASHCADDY_ROOT:-/opt/dashcaddy}"
|
||||||
|
TARGET_DIR="${TARGET_DIR:-/opt/vintage-radio/web}"
|
||||||
|
SOURCE_DIR="${DASHCADDY_ROOT}/dashcaddy-api/static-sites/vintage-radio/web"
|
||||||
|
|
||||||
|
if [[ ! -d "$SOURCE_DIR" ]]; then
|
||||||
|
echo "FATAL: source assets not found at $SOURCE_DIR" >&2
|
||||||
|
echo " Install DashCaddy, or set DASHCADDY_ROOT to its location." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SOURCE_DIR/index.html" || ! -f "$SOURCE_DIR/radio.css" \
|
||||||
|
|| ! -f "$SOURCE_DIR/radio.js" || ! -f "$SOURCE_DIR/stations.json" ]]; then
|
||||||
|
echo "FATAL: incomplete assets in $SOURCE_DIR" >&2
|
||||||
|
ls -la "$SOURCE_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
|
||||||
|
install -m 0644 "$SOURCE_DIR/index.html" "$TARGET_DIR/index.html"
|
||||||
|
install -m 0644 "$SOURCE_DIR/radio.css" "$TARGET_DIR/radio.css"
|
||||||
|
install -m 0644 "$SOURCE_DIR/radio.js" "$TARGET_DIR/radio.js"
|
||||||
|
install -m 0644 "$SOURCE_DIR/stations.json" "$TARGET_DIR/stations.json"
|
||||||
|
|
||||||
|
chmod 0755 "$TARGET_DIR"
|
||||||
|
|
||||||
|
echo "Vintage Stereo assets installed:"
|
||||||
|
echo " Source: $SOURCE_DIR"
|
||||||
|
echo " Target: $TARGET_DIR"
|
||||||
|
ls -la "$TARGET_DIR"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Vintage Stereo</title>
|
||||||
|
<link rel="stylesheet" href="radio.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="room">
|
||||||
|
<div class="console" id="console">
|
||||||
|
|
||||||
|
<!-- ====== LEFT: wood grain end cap, controls column ====== -->
|
||||||
|
<aside class="endcap endcap-left">
|
||||||
|
<button class="knob knob-power" id="powerBtn" type="button" aria-pressed="false" aria-label="Power">
|
||||||
|
<div class="knob-face">
|
||||||
|
<div class="knob-indicator"></div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-label">PWR</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="knob knob-mode" id="modeBtn" type="button" aria-pressed="false" aria-label="Cycle genre mode">
|
||||||
|
<div class="knob-face">
|
||||||
|
<div class="knob-indicator"></div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-label">MODE</span>
|
||||||
|
<span class="knob-mode-name" id="modeName">ALL</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="knob knob-mute" id="muteBtn" type="button" aria-pressed="false" aria-label="Mute">
|
||||||
|
<div class="knob-face">
|
||||||
|
<div class="knob-indicator"></div>
|
||||||
|
</div>
|
||||||
|
<span class="knob-label">MUTE</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ====== CENTER: smoked-glass face revealing controls underneath ====== -->
|
||||||
|
<section class="glass-face" aria-label="Stereo faceplate">
|
||||||
|
<div class="glass-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Backlit dial display visible through the glass -->
|
||||||
|
<div class="dial-window">
|
||||||
|
<div class="dial-frequency" id="dialFrequency">--.-</div>
|
||||||
|
<div class="dial-station" id="dialStation">VINTAGE STEREO</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horizontal slide-rule tuning rail -->
|
||||||
|
<div class="dial-rail-wrap">
|
||||||
|
<button
|
||||||
|
class="dial-rail"
|
||||||
|
id="dialRail"
|
||||||
|
type="button"
|
||||||
|
aria-label="Tuning rail. Drag horizontally or use left and right arrow keys."
|
||||||
|
>
|
||||||
|
<div class="dial-ticks" id="dialTicks"></div>
|
||||||
|
<div class="dial-stop" id="dialStop1"></div>
|
||||||
|
<div class="dial-stop" id="dialStop2"></div>
|
||||||
|
<div class="dial-stop" id="dialStop3"></div>
|
||||||
|
<div class="dial-cursor" id="dialCursor">
|
||||||
|
<div class="cursor-line"></div>
|
||||||
|
<div class="cursor-flag"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="dial-scale">
|
||||||
|
<span>88</span><span>92</span><span>96</span><span>100</span><span>104</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Twin VU meters -->
|
||||||
|
<div class="vu-row">
|
||||||
|
<div class="vu-meter" aria-hidden="true">
|
||||||
|
<div class="vu-falloff" id="vuLeftFalloff"></div>
|
||||||
|
<div class="vu-needle" id="vuLeft"></div>
|
||||||
|
<div class="vu-label">L</div>
|
||||||
|
<div class="vu-bg-marks">
|
||||||
|
<span></span><span></span><span></span><span></span><span></span><span class="red"></span><span class="red"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vu-meter" aria-hidden="true">
|
||||||
|
<div class="vu-falloff" id="vuRightFalloff"></div>
|
||||||
|
<div class="vu-needle" id="vuRight"></div>
|
||||||
|
<div class="vu-label">R</div>
|
||||||
|
<div class="vu-bg-marks">
|
||||||
|
<span></span><span></span><span></span><span></span><span></span><span class="red"></span><span class="red"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Power LED + status row -->
|
||||||
|
<div class="status-row">
|
||||||
|
<span class="led" id="powerLed"></span>
|
||||||
|
<span class="status-text" id="statusText">Standby</span>
|
||||||
|
<span class="led led-signal" id="signalLed"></span>
|
||||||
|
<span class="status-text" id="signalText">Signal</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== RIGHT: knob array + volume slider ====== -->
|
||||||
|
<aside class="endcap endcap-right">
|
||||||
|
<div class="volume-block">
|
||||||
|
<span class="block-label">VOLUME</span>
|
||||||
|
<input id="volumeSlider" type="range" min="0" max="100" value="70" class="volume-slider" aria-label="Volume" />
|
||||||
|
<div class="volume-readout" id="volumeReadout">70</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preset-block">
|
||||||
|
<span class="block-label">PRESETS</span>
|
||||||
|
<div class="preset-buttons">
|
||||||
|
<button class="preset" id="prevBtn" type="button" aria-label="Previous station">◀◀</button>
|
||||||
|
<button class="preset" id="nextBtn" type="button" aria-label="Next station">▶▶</button>
|
||||||
|
</div>
|
||||||
|
<div class="preset-label" id="presetLabel">— / —</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ====== Speaker grille (bottom) ====== -->
|
||||||
|
<div class="grille" aria-hidden="true">
|
||||||
|
<div class="grille-fabric"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== Side panel: station index ====== -->
|
||||||
|
<aside class="panel" id="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h1>STATION INDEX</h1>
|
||||||
|
<p class="panel-sub">tune the dial or click a station</p>
|
||||||
|
</header>
|
||||||
|
<ul class="station-list" id="stationList" role="listbox" aria-label="Available stations"></ul>
|
||||||
|
<footer class="panel-foot">
|
||||||
|
<span id="nowPlaying">Power: standby</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span id="streamInfo"></span>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<audio id="player" preload="none" crossorigin="anonymous"></audio>
|
||||||
|
|
||||||
|
<script src="radio.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
/* Vintage Stereo — glass-front console stereo styling */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--wood-light: #c89466;
|
||||||
|
--wood-mid: #8a5326;
|
||||||
|
--wood-dark: #3e2110;
|
||||||
|
--wood-cap: #2a160a;
|
||||||
|
--brushed: #d4cfc2;
|
||||||
|
--brushed-dk: #807a6e;
|
||||||
|
--face: #b8b2a3;
|
||||||
|
--face-dk: #615d54;
|
||||||
|
--led-off: #341a10;
|
||||||
|
--led-on: #ff5733;
|
||||||
|
--dial-glow: #ffa84a;
|
||||||
|
--vu-glow: #f1c40f;
|
||||||
|
--knob-cap: #1d1814;
|
||||||
|
--ink: #14110a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, #1f140a 0%, #0a0604 80%);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(640px, 1fr) 340px;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.room {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.room > .console { justify-self: center; }
|
||||||
|
.room > .panel { min-height: 60vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very narrow phones: zoom the console down to fit the viewport.
|
||||||
|
Note: `zoom` is supported in Chrome/Edge/Safari and Firefox 126+. Older Firefox
|
||||||
|
falls back to the unzoomed layout (with mild horizontal overflow). */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
html, body { overflow: auto; }
|
||||||
|
.room { padding: 12px; }
|
||||||
|
.room > .console { zoom: 0.92; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.room > .console { zoom: 0.78; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.room > .console { zoom: 0.62; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Console ====== */
|
||||||
|
|
||||||
|
.console {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg,
|
||||||
|
rgba(255,255,255,0.05) 0 2px,
|
||||||
|
transparent 2px 5px),
|
||||||
|
linear-gradient(180deg, var(--wood-light) 0%, var(--wood-mid) 50%, var(--wood-dark) 100%);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.25),
|
||||||
|
inset 0 -30px 80px rgba(0,0,0,0.55),
|
||||||
|
0 30px 80px rgba(0,0,0,0.6),
|
||||||
|
0 0 0 8px var(--wood-cap);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px 1fr 200px;
|
||||||
|
grid-template-rows: 360px 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"left face right"
|
||||||
|
"grille grille grille";
|
||||||
|
min-height: 720px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== End caps (left & right wooden panels with knobs) ====== */
|
||||||
|
|
||||||
|
.endcap {
|
||||||
|
background: linear-gradient(180deg, var(--wood-mid) 0%, var(--wood-dark) 100%);
|
||||||
|
padding: 22px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: inset 8px 0 18px rgba(0,0,0,0.45);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endcap-left { grid-area: left; border-right: 2px solid rgba(0,0,0,0.4); }
|
||||||
|
.endcap-right { grid-area: right; border-left: 2px solid rgba(0,0,0,0.4); box-shadow: inset -8px 0 18px rgba(0,0,0,0.45); }
|
||||||
|
|
||||||
|
/* ====== Knobs ====== */
|
||||||
|
|
||||||
|
.knob {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #f4ead0;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob-face {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 25%, #f0e8d4 0%, #8a7e5e 60%, #1c1610 100%);
|
||||||
|
border: 2px solid #0a0805;
|
||||||
|
box-shadow:
|
||||||
|
0 3px 6px rgba(0,0,0,0.5),
|
||||||
|
inset 0 -1px 2px rgba(255,255,255,0.18),
|
||||||
|
inset 0 2px 4px rgba(255,255,255,0.15);
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob:active .knob-face { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.knob-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 50%;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--led-on);
|
||||||
|
border-radius: 1px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
box-shadow: 0 0 4px var(--led-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob-power[aria-pressed="true"] .knob-indicator {
|
||||||
|
box-shadow: 0 0 10px var(--led-on), 0 0 16px rgba(255,87,51,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--brushed);
|
||||||
|
text-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob-mode-name {
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
background: #1a0d05;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #0a0805;
|
||||||
|
margin-top: -2px;
|
||||||
|
text-shadow: 0 0 3px var(--dial-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Glass face ====== */
|
||||||
|
|
||||||
|
.glass-face {
|
||||||
|
grid-area: face;
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #c4beae 0%, #a39c8b 50%, #7a7363 100%);
|
||||||
|
padding: 28px 32px 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto auto;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The smoked-glass overlay that sits ON TOP of all face contents */
|
||||||
|
.glass-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 14, 6, 0.18) 0%, rgba(20, 14, 6, 0.35) 100%),
|
||||||
|
repeating-linear-gradient(135deg,
|
||||||
|
rgba(255,255,255,0.04) 0 1px,
|
||||||
|
transparent 1px 4px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.45),
|
||||||
|
inset 0 0 30px rgba(0,0,0,0.35);
|
||||||
|
border-left: 2px solid rgba(0,0,0,0.4);
|
||||||
|
border-right: 2px solid rgba(0,0,0,0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-face > *:not(.glass-overlay) { position: relative; z-index: 2; }
|
||||||
|
|
||||||
|
/* Faint streaks like a polished-glass reflection */
|
||||||
|
.glass-face::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg,
|
||||||
|
transparent 30%,
|
||||||
|
rgba(255,255,255,0.18) 38%,
|
||||||
|
transparent 46%,
|
||||||
|
rgba(255,255,255,0.08) 60%,
|
||||||
|
transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Dial window: backlit section behind glass ====== */
|
||||||
|
|
||||||
|
.dial-window {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #1a0d05 0%, #2b1608 100%);
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #0a0805;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 6px rgba(0,0,0,0.7),
|
||||||
|
0 0 12px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-frequency {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
letter-spacing: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow:
|
||||||
|
0 0 8px var(--dial-glow),
|
||||||
|
0 0 18px rgba(255,168,74,0.4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console[data-power="off"] .dial-frequency { color: #4a2b14; text-shadow: none; }
|
||||||
|
|
||||||
|
.dial-station {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
color: #f6e6c8;
|
||||||
|
text-shadow: 0 0 6px rgba(255,176,102,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console[data-power="off"] .dial-station { color: #4a2b14; text-shadow: none; }
|
||||||
|
|
||||||
|
/* ====== Tuning rail ====== */
|
||||||
|
|
||||||
|
.dial-rail-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-rail {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 72px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #161109 0%, #2a1c0b 100%);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid #0a0805;
|
||||||
|
cursor: ew-resize;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-ticks {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg,
|
||||||
|
rgba(255,168,74,0.25) 0 1px,
|
||||||
|
transparent 1px 2px,
|
||||||
|
rgba(255,168,74,0.5) 8px 9px,
|
||||||
|
rgba(255,168,74,0.15) 9px 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-stop {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--dial-glow);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 4px var(--dial-glow);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
bottom: -6px;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: left 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -1px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--led-on);
|
||||||
|
box-shadow: 0 0 6px var(--led-on), 0 0 12px rgba(255,87,51,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-flag {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: -7px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-bottom: 8px solid var(--led-on);
|
||||||
|
filter: drop-shadow(0 0 4px var(--led-on));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dial-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--face-dk);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Twin VU meters ====== */
|
||||||
|
|
||||||
|
.vu-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-meter {
|
||||||
|
position: relative;
|
||||||
|
height: 80px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #f7f0d8 0%, #d8cfb5 100%);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid #0a0805;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-needle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1.5px;
|
||||||
|
height: 100%;
|
||||||
|
background: #c0392b;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transition: transform 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-falloff {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent 49%, rgba(0,0,0,0.15) 50%, transparent 51%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-bg-marks {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 4px;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-bg-marks span {
|
||||||
|
width: 1px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(60, 40, 25, 0.6);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-bg-marks span.red { background: #c0392b; }
|
||||||
|
|
||||||
|
/* ====== Status row under glass ====== */
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--face-dk);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--led-off);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(255,255,255,0.2);
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console[data-power="on"] .led { background: var(--led-on); box-shadow: 0 0 8px var(--led-on), inset 0 1px 1px rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
.led-signal { background: #2a1608; }
|
||||||
|
|
||||||
|
.console[data-power="on"][data-streaming="true"] .led-signal {
|
||||||
|
background: #2ecc71;
|
||||||
|
box-shadow: 0 0 6px #2ecc71, inset 0 1px 1px rgba(255,255,255,0.3);
|
||||||
|
animation: signal-pulse 1.6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes signal-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.55; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text { font-weight: bold; text-transform: uppercase; }
|
||||||
|
|
||||||
|
/* ====== Right end cap: volume + presets ====== */
|
||||||
|
|
||||||
|
.volume-block, .preset-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: var(--brushed);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
width: 28px;
|
||||||
|
height: 100px;
|
||||||
|
accent-color: var(--led-on);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-readout {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
text-shadow: 0 0 6px var(--dial-glow);
|
||||||
|
background: #1a0d05;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #0a0805;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-buttons { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.preset {
|
||||||
|
background: var(--brushed);
|
||||||
|
border: 2px solid var(--brushed-dk);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
box-shadow: inset 0 -2px 3px rgba(0,0,0,0.25), 0 2px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset:active { transform: translateY(1px); box-shadow: inset 0 2px 3px rgba(0,0,0,0.25), 0 0 0 transparent; }
|
||||||
|
|
||||||
|
.preset:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.preset-label {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
text-shadow: 0 0 4px var(--dial-glow);
|
||||||
|
background: #1a0d05;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #0a0805;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Speaker grille (spans full bottom) ====== */
|
||||||
|
|
||||||
|
.grille {
|
||||||
|
grid-area: grille;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg,
|
||||||
|
rgba(0,0,0,0.85) 0 2px,
|
||||||
|
rgba(255,255,255,0.04) 2px 6px);
|
||||||
|
border-top: 4px solid rgba(0,0,0,0.5);
|
||||||
|
box-shadow: inset 0 4px 12px rgba(0,0,0,0.6);
|
||||||
|
position: relative;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille-fabric {
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg,
|
||||||
|
rgba(0,0,0,0.4) 0 3px,
|
||||||
|
rgba(120, 80, 40, 0.2) 3px 6px),
|
||||||
|
radial-gradient(ellipse at center, rgba(0,0,0,0.4) 0%, transparent 70%);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Side panel ====== */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, #1a120a 0%, #0d0805 100%);
|
||||||
|
color: #d4c9a8;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 22px;
|
||||||
|
border: 2px solid var(--wood-dark);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 30px rgba(0,0,0,0.6),
|
||||||
|
0 12px 30px rgba(0,0,0,0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
text-shadow: 0 0 8px var(--dial-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-sub {
|
||||||
|
margin: 4px 0 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list li:hover { background: rgba(255,176,102,0.08); border-color: rgba(255,176,102,0.3); }
|
||||||
|
|
||||||
|
.station-list li[aria-selected="true"] {
|
||||||
|
background: rgba(255,176,102,0.15);
|
||||||
|
border-color: var(--dial-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list li:active { transform: translateX(2px); }
|
||||||
|
|
||||||
|
.station-freq {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--dial-glow);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
|
||||||
|
.station-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f4ead0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-genre {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255,176,102,0.2);
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-foot .sep { opacity: 0.4; }
|
||||||
|
|
||||||
|
#streamInfo.live::before {
|
||||||
|
content: "\25CF";
|
||||||
|
color: var(--led-on);
|
||||||
|
margin-right: 4px;
|
||||||
|
animation: blink 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 60%, 100% { opacity: 1; }
|
||||||
|
30% { opacity: 0.2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list::-webkit-scrollbar { width: 6px; }
|
||||||
|
.station-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
|
||||||
|
.station-list::-webkit-scrollbar-thumb { background: var(--wood-mid); border-radius: 3px; }
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// Vintage Stereo — tuner logic for the glass-front console stereo
|
||||||
|
// Loads stations from /stations.json, manages playback through an <audio>
|
||||||
|
// element, and drives the analog dial / VU meters / status panel.
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
console: document.getElementById('console'),
|
||||||
|
dialRail: document.getElementById('dialRail'),
|
||||||
|
dialCursor: document.getElementById('dialCursor'),
|
||||||
|
dialFrequency: document.getElementById('dialFrequency'),
|
||||||
|
dialStation: document.getElementById('dialStation'),
|
||||||
|
vuLeft: document.getElementById('vuLeft'),
|
||||||
|
vuRight: document.getElementById('vuRight'),
|
||||||
|
powerBtn: document.getElementById('powerBtn'),
|
||||||
|
modeBtn: document.getElementById('modeBtn'),
|
||||||
|
modeName: document.getElementById('modeName'),
|
||||||
|
muteBtn: document.getElementById('muteBtn'),
|
||||||
|
prevBtn: document.getElementById('prevBtn'),
|
||||||
|
nextBtn: document.getElementById('nextBtn'),
|
||||||
|
volumeSlider: document.getElementById('volumeSlider'),
|
||||||
|
volumeReadout: document.getElementById('volumeReadout'),
|
||||||
|
presetLabel: document.getElementById('presetLabel'),
|
||||||
|
stationList: document.getElementById('stationList'),
|
||||||
|
player: document.getElementById('player'),
|
||||||
|
statusText: document.getElementById('statusText'),
|
||||||
|
signalText: document.getElementById('signalText'),
|
||||||
|
nowPlaying: document.getElementById('nowPlaying'),
|
||||||
|
streamInfo: document.getElementById('streamInfo'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_MODES = [
|
||||||
|
{ name: 'ALL', match: () => true },
|
||||||
|
{ name: 'AMBIENT', match: (s) => /ambient|space|lounge|chill|downtempo|nasa/i.test(s.genre + ' ' + s.name) },
|
||||||
|
{ name: 'ROCK', match: (s) => /rock|indie|pop|folk|synth|wave|electronic|secret|beat/i.test(s.genre + ' ' + s.name) },
|
||||||
|
{ name: 'MIXED', match: (s) => /paradise|eclectic|mix|indie|kexp|public/i.test(s.genre + ' ' + s.name) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
stations: [],
|
||||||
|
visibleStations: [],
|
||||||
|
currentIndex: -1,
|
||||||
|
power: false,
|
||||||
|
muted: false,
|
||||||
|
volume: 0.7,
|
||||||
|
filterMode: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ====== Loading ======
|
||||||
|
|
||||||
|
async function loadStations() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('stations.json', { cache: 'no-cache' });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
STATE.stations = (data.stations || [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.freq - b.freq);
|
||||||
|
applyFilter();
|
||||||
|
if (STATE.visibleStations.length > 0) {
|
||||||
|
tuneTo(0);
|
||||||
|
} else {
|
||||||
|
setStatus('No stations in this mode');
|
||||||
|
els.dialStation.textContent = 'NO STATIONS';
|
||||||
|
}
|
||||||
|
updatePrevNextDisabled();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('Error: ' + err.message);
|
||||||
|
els.dialStation.textContent = 'OFFLINE';
|
||||||
|
els.dialFrequency.textContent = '---.-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const mode = FILTER_MODES[STATE.filterMode];
|
||||||
|
const filtered = STATE.stations.filter(mode.match);
|
||||||
|
STATE.visibleStations = filtered.length > 0 ? filtered : STATE.stations.slice();
|
||||||
|
renderStationList();
|
||||||
|
updatePresetLabel();
|
||||||
|
const cur = STATE.stations[STATE.currentIndex];
|
||||||
|
if (!cur || !STATE.visibleStations.includes(cur)) {
|
||||||
|
// Current station was filtered out — pick the visible station closest by frequency
|
||||||
|
// to the current station's frequency (not always the first visible station).
|
||||||
|
if (STATE.visibleStations.length > 0) {
|
||||||
|
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||||
|
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - (cur ? cur.freq : 0));
|
||||||
|
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||||
|
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||||
|
const d = Math.abs(STATE.stations[real].freq - (cur ? cur.freq : 0));
|
||||||
|
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||||
|
}
|
||||||
|
if (bestRealIdx !== STATE.currentIndex) {
|
||||||
|
STATE.currentIndex = bestRealIdx;
|
||||||
|
updateDialFromStation();
|
||||||
|
updateStationListSelection();
|
||||||
|
// Station changed — restart playback to match the displayed selection.
|
||||||
|
if (STATE.power) startStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (STATE.power) {
|
||||||
|
// Current station is still in the filtered set, but MODE has changed — restart
|
||||||
|
// playback so any per-mode audio-affecting state (volume, readyState) catches up.
|
||||||
|
startStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Rendering ======
|
||||||
|
|
||||||
|
function renderStationList() {
|
||||||
|
els.stationList.innerHTML = '';
|
||||||
|
STATE.visibleStations.forEach((s) => {
|
||||||
|
const realIdx = STATE.stations.indexOf(s);
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.setAttribute('role', 'option');
|
||||||
|
li.dataset.index = String(realIdx);
|
||||||
|
const freq = document.createElement('span');
|
||||||
|
freq.className = 'station-freq';
|
||||||
|
freq.textContent = s.freq.toFixed(1);
|
||||||
|
const info = document.createElement('span');
|
||||||
|
info.className = 'station-info';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'station-name';
|
||||||
|
name.textContent = s.name;
|
||||||
|
const genre = document.createElement('span');
|
||||||
|
genre.className = 'station-genre';
|
||||||
|
genre.textContent = s.genre;
|
||||||
|
info.appendChild(name);
|
||||||
|
info.appendChild(genre);
|
||||||
|
li.appendChild(freq);
|
||||||
|
li.appendChild(info);
|
||||||
|
li.addEventListener('click', () => {
|
||||||
|
tuneTo(realIdx);
|
||||||
|
// tuneTo() already restarts the stream if powered — no need to also play().
|
||||||
|
});
|
||||||
|
els.stationList.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStationListSelection() {
|
||||||
|
els.stationList.querySelectorAll('li').forEach((li) => {
|
||||||
|
const idx = Number(li.dataset.index);
|
||||||
|
li.setAttribute('aria-selected', idx === STATE.currentIndex ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
const sel = els.stationList.querySelector('li[aria-selected="true"]');
|
||||||
|
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePresetLabel() {
|
||||||
|
const total = STATE.visibleStations.length;
|
||||||
|
const cur = total > 0 ? (visibleIndexOfCurrent() + 1) : 0;
|
||||||
|
els.presetLabel.textContent = cur.toString().padStart(2, '0') + ' / ' + total.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleIndexOfCurrent() {
|
||||||
|
if (STATE.currentIndex < 0) return -1;
|
||||||
|
const cur = STATE.stations[STATE.currentIndex];
|
||||||
|
return STATE.visibleStations.indexOf(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Tuning ======
|
||||||
|
|
||||||
|
function updateDialFromStation() {
|
||||||
|
if (STATE.currentIndex < 0 || STATE.stations.length === 0) return;
|
||||||
|
const s = STATE.stations[STATE.currentIndex];
|
||||||
|
const t = (s.freq - 88) / (105.4 - 88);
|
||||||
|
const pct = Math.max(0, Math.min(1, t)) * 100;
|
||||||
|
els.dialCursor.style.left = pct + '%';
|
||||||
|
els.dialFrequency.textContent = s.freq.toFixed(1);
|
||||||
|
els.dialStation.textContent = s.name.toUpperCase();
|
||||||
|
els.nowPlaying.textContent = s.name + ' \u00b7 ' + s.genre;
|
||||||
|
updatePresetLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuneTo(index) {
|
||||||
|
if (index < 0 || index >= STATE.stations.length) return;
|
||||||
|
if (!STATE.visibleStations.includes(STATE.stations[index])) {
|
||||||
|
// Defensive: caller asked for a filtered-out station — pick the closest visible
|
||||||
|
// station by frequency instead of resetting the active filter.
|
||||||
|
const targetFreq = STATE.stations[index].freq;
|
||||||
|
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||||
|
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - targetFreq);
|
||||||
|
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||||
|
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||||
|
const d = Math.abs(STATE.stations[real].freq - targetFreq);
|
||||||
|
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||||
|
}
|
||||||
|
index = bestRealIdx;
|
||||||
|
}
|
||||||
|
STATE.currentIndex = index;
|
||||||
|
updateDialFromStation();
|
||||||
|
updateStationListSelection();
|
||||||
|
updatePrevNextDisabled();
|
||||||
|
if (STATE.power) startStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuneToVisibleIndex(vi) {
|
||||||
|
if (vi < 0 || vi >= STATE.visibleStations.length) return;
|
||||||
|
const target = STATE.visibleStations[vi];
|
||||||
|
const realIdx = STATE.stations.indexOf(target);
|
||||||
|
if (realIdx !== STATE.currentIndex) tuneTo(realIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuneToFreq(freq) {
|
||||||
|
if (STATE.visibleStations.length === 0) return;
|
||||||
|
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||||
|
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - freq);
|
||||||
|
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||||
|
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||||
|
const d = Math.abs(STATE.stations[real].freq - freq);
|
||||||
|
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||||
|
}
|
||||||
|
if (bestRealIdx !== STATE.currentIndex) tuneTo(bestRealIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Playback ======
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const s = STATE.stations[STATE.currentIndex];
|
||||||
|
if (!s) return;
|
||||||
|
const targetUrl = s.url;
|
||||||
|
if (els.player.src !== targetUrl) {
|
||||||
|
els.player.src = targetUrl;
|
||||||
|
els.player.load();
|
||||||
|
} else {
|
||||||
|
// Same URL, but caller wants a fresh start — rewind and reload to flush
|
||||||
|
// any buffered state from a previous mode/stream.
|
||||||
|
try { els.player.currentTime = 0; } catch (_) { /* some streams reject */ }
|
||||||
|
els.player.load();
|
||||||
|
}
|
||||||
|
const playPromise = els.player.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
|
playPromise.catch((err) => {
|
||||||
|
setStatus('Audio error: ' + err.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
try { els.player.pause(); } catch (_) { /* ignore */ }
|
||||||
|
els.player.removeAttribute('src');
|
||||||
|
els.player.load();
|
||||||
|
els.console.dataset.streaming = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (!STATE.power) return;
|
||||||
|
startStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Power ======
|
||||||
|
|
||||||
|
function setPower(on) {
|
||||||
|
STATE.power = on;
|
||||||
|
els.console.dataset.power = on ? 'on' : 'off';
|
||||||
|
els.powerBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
setStatus(on ? 'Power on' : 'Standby');
|
||||||
|
setSignal(on ? 'Tuning' : 'Idle', on);
|
||||||
|
if (on) startStream();
|
||||||
|
else stopStream();
|
||||||
|
updatePrevNextDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePrevNextDisabled() {
|
||||||
|
const visibleIdx = visibleIndexOfCurrent();
|
||||||
|
const total = STATE.visibleStations.length;
|
||||||
|
const canPrev = visibleIdx > 0;
|
||||||
|
const canNext = visibleIdx >= 0 && visibleIdx < total - 1;
|
||||||
|
els.prevBtn.disabled = !canPrev;
|
||||||
|
els.nextBtn.disabled = !canNext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Volume / Mute ======
|
||||||
|
|
||||||
|
function applyVolume() {
|
||||||
|
const v = STATE.muted ? 0 : STATE.volume;
|
||||||
|
els.player.volume = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
STATE.muted = !STATE.muted;
|
||||||
|
els.muteBtn.setAttribute('aria-pressed', STATE.muted ? 'true' : 'false');
|
||||||
|
applyVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
els.statusText.textContent = msg;
|
||||||
|
if (!STATE.power) els.nowPlaying.textContent = 'Power: ' + msg.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSignal(msg, on) {
|
||||||
|
els.signalText.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Mode (genre filter) ======
|
||||||
|
|
||||||
|
function cycleMode() {
|
||||||
|
STATE.filterMode = (STATE.filterMode + 1) % FILTER_MODES.length;
|
||||||
|
applyFilter();
|
||||||
|
const name = FILTER_MODES[STATE.filterMode].name;
|
||||||
|
els.modeName.textContent = name;
|
||||||
|
els.modeBtn.setAttribute('aria-label', 'Cycle genre mode. Currently ' + name + '.');
|
||||||
|
setStatus('Mode: ' + name);
|
||||||
|
updatePrevNextDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== VU meter animation ======
|
||||||
|
|
||||||
|
let vuAnimHandle = null;
|
||||||
|
let leftEnergy = 0;
|
||||||
|
let rightEnergy = 0;
|
||||||
|
|
||||||
|
function animateVu() {
|
||||||
|
if (!STATE.power) {
|
||||||
|
els.vuLeft.style.transform = 'rotate(0deg)';
|
||||||
|
els.vuRight.style.transform = 'rotate(0deg)';
|
||||||
|
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (els.player.paused || els.player.readyState < 2) {
|
||||||
|
leftEnergy = leftEnergy * 0.85 + (Math.random() * 4 - 2) * 0.15;
|
||||||
|
rightEnergy = rightEnergy * 0.85 + (Math.random() * 4 - 2) * 0.15;
|
||||||
|
} else {
|
||||||
|
const base = -8;
|
||||||
|
const peak = Math.random() < 0.06 ? 32 : Math.random() * 16;
|
||||||
|
const l = base + peak + (Math.random() - 0.5) * 5;
|
||||||
|
const r = base + peak + (Math.random() - 0.5) * 5;
|
||||||
|
leftEnergy = leftEnergy * 0.6 + l * 0.4;
|
||||||
|
rightEnergy = rightEnergy * 0.6 + r * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.vuLeft.style.transform = 'rotate(' + leftEnergy.toFixed(1) + 'deg)';
|
||||||
|
els.vuRight.style.transform = 'rotate(' + rightEnergy.toFixed(1) + 'deg)';
|
||||||
|
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Dial interaction ======
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
|
||||||
|
function railXToFreq(clientX) {
|
||||||
|
const rect = els.dialRail.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
|
||||||
|
const t = x / rect.width;
|
||||||
|
return 88 + t * (105.4 - 88);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialPointerDown(e) {
|
||||||
|
dragging = true;
|
||||||
|
els.dialRail.setPointerCapture(e.pointerId);
|
||||||
|
tuneToFreq(railXToFreq(e.clientX));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialPointerMove(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
tuneToFreq(railXToFreq(e.clientX));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialPointerUp(e) {
|
||||||
|
dragging = false;
|
||||||
|
try { els.dialRail.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (STATE.visibleStations.length === 0) return;
|
||||||
|
const dir = e.deltaY > 0 ? 1 : -1;
|
||||||
|
const vi = visibleIndexOfCurrent();
|
||||||
|
tuneToVisibleIndex(Math.max(0, Math.min(STATE.visibleStations.length - 1, vi + dir)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialKey(e) {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const vi = visibleIndexOfCurrent();
|
||||||
|
if (vi > 0) tuneToVisibleIndex(vi - 1);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const vi = visibleIndexOfCurrent();
|
||||||
|
if (vi >= 0 && vi < STATE.visibleStations.length - 1) tuneToVisibleIndex(vi + 1);
|
||||||
|
} else if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleMute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Streaming indicator ======
|
||||||
|
|
||||||
|
function updateStreamIndicator() {
|
||||||
|
const streaming = STATE.power
|
||||||
|
&& !els.player.paused
|
||||||
|
&& els.player.readyState >= 2
|
||||||
|
&& els.player.error === null;
|
||||||
|
els.console.dataset.streaming = streaming ? 'true' : 'false';
|
||||||
|
if (STATE.power) {
|
||||||
|
if (streaming) {
|
||||||
|
const s = STATE.stations[STATE.currentIndex];
|
||||||
|
els.streamInfo.textContent = s ? s.name : '';
|
||||||
|
els.streamInfo.classList.add('live');
|
||||||
|
setSignal('Streaming', true);
|
||||||
|
} else if (els.player.error) {
|
||||||
|
setSignal('No signal', false);
|
||||||
|
els.streamInfo.classList.remove('live');
|
||||||
|
els.streamInfo.textContent = '';
|
||||||
|
} else {
|
||||||
|
setSignal('Tuning', true);
|
||||||
|
els.streamInfo.classList.remove('live');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
els.streamInfo.classList.remove('live');
|
||||||
|
els.streamInfo.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Wire up ======
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
els.console.dataset.power = 'off';
|
||||||
|
els.console.dataset.streaming = 'false';
|
||||||
|
els.player.volume = STATE.volume;
|
||||||
|
els.modeName.textContent = FILTER_MODES[STATE.filterMode].name;
|
||||||
|
els.modeBtn.setAttribute('aria-label', 'Cycle genre mode. Currently ' + FILTER_MODES[STATE.filterMode].name + '.');
|
||||||
|
|
||||||
|
els.player.addEventListener('playing', updateStreamIndicator);
|
||||||
|
els.player.addEventListener('pause', updateStreamIndicator);
|
||||||
|
els.player.addEventListener('waiting', updateStreamIndicator);
|
||||||
|
els.player.addEventListener('stalled', updateStreamIndicator);
|
||||||
|
els.player.addEventListener('error', () => {
|
||||||
|
setSignal('No signal', false);
|
||||||
|
els.streamInfo.classList.remove('live');
|
||||||
|
els.streamInfo.textContent = 'stream error';
|
||||||
|
});
|
||||||
|
|
||||||
|
els.powerBtn.addEventListener('click', () => setPower(!STATE.power));
|
||||||
|
els.muteBtn.addEventListener('click', toggleMute);
|
||||||
|
els.modeBtn.addEventListener('click', cycleMode);
|
||||||
|
|
||||||
|
els.prevBtn.addEventListener('click', () => {
|
||||||
|
const vi = visibleIndexOfCurrent();
|
||||||
|
if (vi > 0) tuneToVisibleIndex(vi - 1);
|
||||||
|
});
|
||||||
|
els.nextBtn.addEventListener('click', () => {
|
||||||
|
const vi = visibleIndexOfCurrent();
|
||||||
|
if (vi >= 0 && vi < STATE.visibleStations.length - 1) tuneToVisibleIndex(vi + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.volumeSlider.addEventListener('input', (e) => {
|
||||||
|
const pct = Number(e.target.value);
|
||||||
|
STATE.volume = pct / 100;
|
||||||
|
els.volumeReadout.textContent = pct;
|
||||||
|
if (STATE.muted && pct > 0) toggleMute();
|
||||||
|
applyVolume();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.dialRail.addEventListener('pointerdown', onDialPointerDown);
|
||||||
|
els.dialRail.addEventListener('pointermove', onDialPointerMove);
|
||||||
|
els.dialRail.addEventListener('pointerup', onDialPointerUp);
|
||||||
|
els.dialRail.addEventListener('pointercancel', onDialPointerUp);
|
||||||
|
els.dialRail.addEventListener('wheel', onDialWheel, { passive: false });
|
||||||
|
els.dialRail.addEventListener('keydown', onDialKey);
|
||||||
|
|
||||||
|
setInterval(updateStreamIndicator, 1500);
|
||||||
|
|
||||||
|
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||||
|
loadStations();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"stations": [
|
||||||
|
{ "freq": 88.5, "name": "Groove Salad", "genre": "Ambient / Downtempo", "url": "https://ice1.somafm.com/groovesalad-128-mp3", "color": "#7cb342" },
|
||||||
|
{ "freq": 89.2, "name": "Drone Zone", "genre": "Ambient / Space", "url": "https://ice1.somafm.com/dronezone-128-mp3", "color": "#26c6da" },
|
||||||
|
{ "freq": 90.1, "name": "Deep Space One", "genre": "Ambient / Electronic", "url": "https://ice1.somafm.com/deepspaceone-128-mp3", "color": "#5c6bc0" },
|
||||||
|
{ "freq": 91.3, "name": "Lush", "genre": "Vocal Electronica", "url": "https://ice1.somafm.com/lush-128-mp3", "color": "#ab47bc" },
|
||||||
|
{ "freq": 92.7, "name": "Underground 80s", "genre": "Early New Wave", "url": "https://ice1.somafm.com/u80s-128-mp3", "color": "#ec407a" },
|
||||||
|
{ "freq": 93.5, "name": "Indie Pop Rocks!", "genre": "Indie Pop", "url": "https://ice1.somafm.com/indiepop-128-mp3", "color": "#ff7043" },
|
||||||
|
{ "freq": 94.9, "name": "Mission Control", "genre": "NASA Audio / Talk", "url": "https://ice2.somafm.com/missioncontrol-128-mp3", "color": "#8d6e63" },
|
||||||
|
{ "freq": 95.6, "name": "cliqhop idm", "genre": "IDM / Experimental", "url": "https://ice2.somafm.com/cliqhop-128-mp3", "color": "#42a5f5" },
|
||||||
|
{ "freq": 96.4, "name": "Folk Forward", "genre": "Contemporary Folk", "url": "https://ice2.somafm.com/folkfwd-128-mp3", "color": "#d4a373" },
|
||||||
|
{ "freq": 97.2, "name": "Left Coast 70s", "genre": "Classic Rock", "url": "https://ice2.somafm.com/seventies-128-mp3", "color": "#ffb300" },
|
||||||
|
{ "freq": 98.0, "name": "SF 10\u201333", "genre": "Ambient / Chill", "url": "https://ice1.somafm.com/sf1033-128-mp3", "color": "#26a69a" },
|
||||||
|
{ "freq": 98.8, "name": "Space Station Soma", "genre": "Ambient / Electronic", "url": "https://ice2.somafm.com/spacestation-128-mp3", "color": "#7e57c2" },
|
||||||
|
{ "freq": 99.6, "name": "Suburbs of Goa", "genre": "Desi-Inspired Electronica", "url": "https://ice2.somafm.com/suburbsofgoa-128-mp3", "color": "#fdd835" },
|
||||||
|
{ "freq": 100.4, "name": "Secret Agent", "genre": "Lounge / Spy Jazz", "url": "https://ice1.somafm.com/secretagent-128-mp3", "color": "#5d4037" },
|
||||||
|
{ "freq": 101.8, "name": "Beat Blender", "genre": "Deep House / Downtempo", "url": "https://ice2.somafm.com/beatblender-128-mp3", "color": "#ef5350" },
|
||||||
|
{ "freq": 102.5, "name": "Synphaera Radio", "genre": "Vaporwave / Future Funk", "url": "https://ice2.somafm.com/synphaera-128-mp3", "color": "#ff80ab" },
|
||||||
|
{ "freq": 103.6, "name": "Radio Paradise", "genre": "Eclectic Main Mix", "url": "https://stream.radioparadise.com/aac-128", "color": "#43a047" },
|
||||||
|
{ "freq": 105.4, "name": "KEXP Seattle", "genre": "Public Radio / Indie", "url": "https://kexp-mp3-128.streamguys1.com/kexp128.mp3", "color": "#1e88e5" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+38
-37
File diff suppressed because one or more lines are too long
Vendored
+288
-249
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+63
-1
@@ -654,6 +654,23 @@
|
|||||||
<!-- Will be filled dynamically -->
|
<!-- Will be filled dynamically -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk safety info: health checks accumulate data over time -->
|
||||||
|
<div style="margin-top: 16px; padding: 14px 16px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 10%, transparent); border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||||
|
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||||
|
<span style="font-size: 1.2rem; line-height: 1;">💾</span>
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage & Health-Check Data</strong>
|
||||||
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted); line-height: 1.45;">
|
||||||
|
DashCaddy's health checks log response times, uptime history, and incidents for every monitored service.
|
||||||
|
Over time this data accumulates and can consume significant disk space — especially on small VPS or
|
||||||
|
SD-card installs. After setup, open <strong>Health → Configure → Global Settings</strong> to set a
|
||||||
|
<strong>data retention period</strong> (default 30 days), adjust the <strong>polling interval</strong>,
|
||||||
|
and configure a <strong>disk-usage warning threshold</strong> so you're alerted before storage runs low.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||||
@@ -663,7 +680,49 @@
|
|||||||
|
|
||||||
<div class="setup-wizard-buttons">
|
<div class="setup-wizard-buttons">
|
||||||
<button id="setup-summary-back">← Back</button>
|
<button id="setup-summary-back">← Back</button>
|
||||||
<button id="setup-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
<button id="setup-summary-next" class="setup-btn-primary">Continue →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk Safety Warning step (shown after the configuration summary) -->
|
||||||
|
<div class="setup-step" id="setup-step-disk-safety" style="display: none;">
|
||||||
|
<h2 style="margin: 0 0 8px;">⚠️ Disk Usage Note</h2>
|
||||||
|
<p class="setup-desc">Important information about storage before you finish</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 8px; padding: 18px 20px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 12%, transparent); border-radius: 10px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: flex-start;">
|
||||||
|
<span style="font-size: 1.5rem; line-height: 1.2;">⚠️</span>
|
||||||
|
<div style="font-size: 0.92rem; line-height: 1.55; color: var(--text);">
|
||||||
|
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage Note:</strong>
|
||||||
|
DashCaddy stores health check history, container statistics, and event logs.
|
||||||
|
On a busy server, this data can accumulate over time.
|
||||||
|
Set appropriate retention limits in <strong>Settings → Health</strong> to prevent disk fill.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; padding: 14px 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<strong style="font-size: 0.9rem;">📋 Recommended after setup</strong>
|
||||||
|
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 0.85rem; color: var(--muted); line-height: 1.6;">
|
||||||
|
<li>Open <strong>Health → Configure → Global Settings</strong></li>
|
||||||
|
<li>Set a <strong>health check polling interval</strong> (default: 60s)</li>
|
||||||
|
<li>Set a <strong>stats polling interval</strong> (default: 30s)</li>
|
||||||
|
<li>Set a <strong>data retention period</strong> (default: 30 days)</li>
|
||||||
|
<li>Cap <strong>max entries per service</strong> (default: 500)</li>
|
||||||
|
<li>Set a <strong>disk-usage warning threshold</strong> (default: 80%)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||||
|
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||||
|
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||||
|
Go to Settings → System Configuration to edit your setup anytime
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-wizard-buttons">
|
||||||
|
<button id="setup-disk-safety-back">← Back</button>
|
||||||
|
<button id="setup-disk-safety-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,6 +1018,9 @@
|
|||||||
<script src="/js/language-selector.js" defer></script>
|
<script src="/js/language-selector.js" defer></script>
|
||||||
|
|
||||||
<!-- Bundled JS (built with: npm run build) -->
|
<!-- Bundled JS (built with: npm run build) -->
|
||||||
|
<!-- i18n (language selector + translation system) -->
|
||||||
|
<script src="/js/i18n.js" defer></script>
|
||||||
|
|
||||||
<script src="/dist/core.js" defer></script>
|
<script src="/dist/core.js" defer></script>
|
||||||
<script src="/dist/features.js" defer></script>
|
<script src="/dist/features.js" defer></script>
|
||||||
<script src="/dist/onboarding.js" defer></script>
|
<script src="/dist/onboarding.js" defer></script>
|
||||||
|
|||||||
+19
-1
@@ -1,10 +1,28 @@
|
|||||||
// ===== DASHBOARD CONSTANTS =====
|
// ===== DASHBOARD CONSTANTS =====
|
||||||
|
// Honor persisted health retention settings for polling cadences so the
|
||||||
|
// Settings → Health → Global Settings panel actually takes effect. The
|
||||||
|
// STATS interval (resource/container stat sampling) is driven from the
|
||||||
|
// user-configurable statsPollingInterval. HEALTH is the lightweight card
|
||||||
|
// badge refresh and stays at its fast default unless overridden.
|
||||||
|
(function applyHealthPollingSettings() {
|
||||||
|
try {
|
||||||
|
var raw = (typeof localStorage !== 'undefined' && localStorage.getItem('dashcaddy-health-settings')) || null;
|
||||||
|
if (raw) {
|
||||||
|
var s = JSON.parse(raw);
|
||||||
|
// values are stored in seconds; DC.POLL expects milliseconds
|
||||||
|
if (s.statsPollingInterval && s.statsPollingInterval >= 5 && s.statsPollingInterval <= 3600) {
|
||||||
|
window.__DC_STATS_OVERRIDE = s.statsPollingInterval * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore — fall back to defaults below */ }
|
||||||
|
})();
|
||||||
|
|
||||||
const DC = {
|
const DC = {
|
||||||
NAME: 'DashCaddy',
|
NAME: 'DashCaddy',
|
||||||
POLL: {
|
POLL: {
|
||||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||||
LOGS: 3000, // 3s — log viewer updates
|
LOGS: 3000, // 3s — log viewer updates
|
||||||
STATS: 5000, // 5s — resource monitor refresh
|
STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health)
|
||||||
WEATHER: 600000, // 10m — weather widget refresh
|
WEATHER: 600000, // 10m — weather widget refresh
|
||||||
HEALTH: 1000, // 1s — card health badge refresh
|
HEALTH: 1000, // 1s — card health badge refresh
|
||||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||||
|
|||||||
@@ -34,6 +34,44 @@
|
|||||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Settings: retention, polling intervals, max entries, disk-usage threshold -->
|
||||||
|
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||||
|
<h4 style="margin: 0 0 4px;">🌍 Global Settings</h4>
|
||||||
|
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||||
|
<div>
|
||||||
|
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||||
|
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||||
|
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||||
|
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||||
|
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||||
|
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||||
|
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-muted-sm">Data Retention (days)</label>
|
||||||
|
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
|
||||||
|
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
|
||||||
|
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
|
||||||
|
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
|
||||||
|
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
|
||||||
|
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
|
||||||
|
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Health Check Form -->
|
<!-- Add/Edit Health Check Form -->
|
||||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||||
@@ -103,6 +141,77 @@
|
|||||||
const formCancel = document.getElementById('health-form-cancel');
|
const formCancel = document.getElementById('health-form-cancel');
|
||||||
const formSave = document.getElementById('health-form-save');
|
const formSave = document.getElementById('health-form-save');
|
||||||
|
|
||||||
|
// ---- Global health settings (retention, polling intervals, max entries, disk threshold) ----
|
||||||
|
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
|
||||||
|
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 60, statsPollingInterval: 30, maxEntriesPerService: 500, diskUsageThreshold: 80 };
|
||||||
|
const globalSaveBtn = document.getElementById('health-global-save');
|
||||||
|
const globalResetBtn = document.getElementById('health-global-reset');
|
||||||
|
const globalStatusSpan = document.getElementById('health-global-status');
|
||||||
|
const retentionInput = document.getElementById('health-setting-retention');
|
||||||
|
const intervalInput = document.getElementById('health-setting-interval');
|
||||||
|
const statsIntervalInput = document.getElementById('health-setting-stats-interval');
|
||||||
|
const maxEntriesInput = document.getElementById('health-setting-max-entries');
|
||||||
|
const diskThresholdInput = document.getElementById('health-setting-disk-threshold');
|
||||||
|
|
||||||
|
function loadHealthSettings() {
|
||||||
|
try {
|
||||||
|
const raw = safeGet(HEALTH_SETTINGS_KEY);
|
||||||
|
const saved = raw ? JSON.parse(raw) : {};
|
||||||
|
return Object.assign({}, HEALTH_DEFAULTS, saved);
|
||||||
|
} catch (_) {
|
||||||
|
return Object.assign({}, HEALTH_DEFAULTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHealthSettingsToUI() {
|
||||||
|
const s = loadHealthSettings();
|
||||||
|
if (retentionInput) retentionInput.value = s.retentionDays;
|
||||||
|
if (intervalInput) intervalInput.value = s.pollingInterval;
|
||||||
|
if (statsIntervalInput) statsIntervalInput.value = s.statsPollingInterval;
|
||||||
|
if (maxEntriesInput) maxEntriesInput.value = s.maxEntriesPerService;
|
||||||
|
if (diskThresholdInput) diskThresholdInput.value = s.diskUsageThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHealthSettings() {
|
||||||
|
const settings = {
|
||||||
|
retentionDays: Math.max(1, Math.min(3650, parseInt(retentionInput?.value) || HEALTH_DEFAULTS.retentionDays)),
|
||||||
|
pollingInterval: Math.max(5, Math.min(3600, parseInt(intervalInput?.value) || HEALTH_DEFAULTS.pollingInterval)),
|
||||||
|
statsPollingInterval: Math.max(5, Math.min(3600, parseInt(statsIntervalInput?.value) || HEALTH_DEFAULTS.statsPollingInterval)),
|
||||||
|
maxEntriesPerService: Math.max(10, Math.min(100000, parseInt(maxEntriesInput?.value) || HEALTH_DEFAULTS.maxEntriesPerService)),
|
||||||
|
diskUsageThreshold: Math.max(50, Math.min(99, parseInt(diskThresholdInput?.value) || HEALTH_DEFAULTS.diskUsageThreshold))
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
applyHealthSettingsToUI();
|
||||||
|
if (globalStatusSpan) {
|
||||||
|
globalStatusSpan.textContent = 'Saved ✓';
|
||||||
|
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||||
|
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||||
|
}
|
||||||
|
if (typeof showNotification === 'function') showNotification('Global health settings saved', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (globalStatusSpan) { globalStatusSpan.textContent = 'Save failed'; globalStatusSpan.style.color = 'var(--bad-fg)'; }
|
||||||
|
if (typeof showNotification === 'function') showNotification('Failed to save settings: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHealthSettings() {
|
||||||
|
try {
|
||||||
|
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(HEALTH_DEFAULTS));
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
applyHealthSettingsToUI();
|
||||||
|
if (globalStatusSpan) {
|
||||||
|
globalStatusSpan.textContent = 'Reset to defaults ✓';
|
||||||
|
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||||
|
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyHealthSettingsToUI();
|
||||||
|
globalSaveBtn?.addEventListener('click', saveHealthSettings);
|
||||||
|
globalResetBtn?.addEventListener('click', resetHealthSettings);
|
||||||
|
// ---- End global health settings ----
|
||||||
|
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
|
|
||||||
function uptimeColor(pct) {
|
function uptimeColor(pct) {
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* DC-077: i18n Frontend — Language selector and translation system
|
||||||
|
*
|
||||||
|
* Provides window.DCI18n.t(key) for the dashboard frontend.
|
||||||
|
* Loads translations from /api/v1/i18n/translations/:lang
|
||||||
|
* Language preference stored in localStorage.
|
||||||
|
* Handles RTL for Arabic.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'dashcaddy-language';
|
||||||
|
const DEFAULT_LANG = 'en';
|
||||||
|
// Must match the 31 languages in language-selector.js and the backend i18n route.
|
||||||
|
const SUPPORTED_LANGS = [
|
||||||
|
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
|
||||||
|
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
|
||||||
|
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
|
||||||
|
];
|
||||||
|
const LANG_NAMES = {
|
||||||
|
en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
|
||||||
|
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
|
||||||
|
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
|
||||||
|
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
|
||||||
|
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
|
||||||
|
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
|
||||||
|
};
|
||||||
|
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
|
||||||
|
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
|
||||||
|
|
||||||
|
// Validate the stored language — if it's invalid (old/corrupt), fall back to default.
|
||||||
|
// Wrap in try/catch for environments where localStorage is disabled (private mode).
|
||||||
|
function _readValidLang() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||||
|
} catch (e) { /* localStorage unavailable */ }
|
||||||
|
return DEFAULT_LANG;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLang = _readValidLang();
|
||||||
|
let translations = {};
|
||||||
|
let loaded = false;
|
||||||
|
// Monotonic token to guard against out-of-order async resolution.
|
||||||
|
// Each setLanguage / loadTranslations call captures the current value; if it
|
||||||
|
// changed by the time the fetch resolves, the result is discarded.
|
||||||
|
let _langRequestId = 0;
|
||||||
|
|
||||||
|
async function loadTranslations(lang, reqId) {
|
||||||
|
// reqId is the monotonic token incremented by the caller (setLanguage/init).
|
||||||
|
// If not provided (direct API call), allocate one for backward compatibility.
|
||||||
|
if (reqId === undefined) reqId = ++_langRequestId;
|
||||||
|
if (lang === DEFAULT_LANG) {
|
||||||
|
translations = {}; // English is the default — no translation needed
|
||||||
|
loaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
|
||||||
|
// Guard against out-of-order resolution: if another loadTranslations
|
||||||
|
// started after this one (or the user switched languages), discard.
|
||||||
|
if (reqId !== _langRequestId) return;
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (reqId !== _langRequestId) return; // double-check after second await
|
||||||
|
translations = data.translations || {};
|
||||||
|
loaded = true;
|
||||||
|
} else {
|
||||||
|
// HTTP error — clear stale translations so we don't show the wrong language
|
||||||
|
translations = {};
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||||
|
if (reqId === _langRequestId) {
|
||||||
|
translations = {};
|
||||||
|
loaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
if (currentLang === DEFAULT_LANG) return key;
|
||||||
|
// If translations didn't load, fall back to the English key
|
||||||
|
return translations[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLanguage(lang) {
|
||||||
|
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||||
|
currentLang = lang;
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
|
||||||
|
|
||||||
|
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
|
||||||
|
const isRtl = RTL_LANGS.has(lang);
|
||||||
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
|
||||||
|
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
||||||
|
loadTranslations(lang, reqId).then(() => {
|
||||||
|
// Only apply if this is still the latest request.
|
||||||
|
if (reqId === _langRequestId) applyTranslations();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguage() {
|
||||||
|
return currentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations() {
|
||||||
|
// Apply translations to elements with data-i18n attributes.
|
||||||
|
// Always write the resolved value — when switching back to English or when a
|
||||||
|
// key has no translation, this restores the original English text rather than
|
||||||
|
// leaving the previous language's translated text visible.
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
el.textContent = t(key);
|
||||||
|
});
|
||||||
|
// Apply to placeholders
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-placeholder');
|
||||||
|
el.placeholder = t(key);
|
||||||
|
});
|
||||||
|
// Apply to titles
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-title');
|
||||||
|
el.title = t(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLanguageSelector() {
|
||||||
|
// Find the top bar area to insert the selector
|
||||||
|
// Look for the auth-settings area or the header actions
|
||||||
|
const targetContainer = document.querySelector('.header-actions') ||
|
||||||
|
document.querySelector('#auth-settings-btn')?.parentElement ||
|
||||||
|
document.querySelector('.top-bar-actions');
|
||||||
|
|
||||||
|
if (!targetContainer) {
|
||||||
|
// If we can't find a target, try to add it near the settings button
|
||||||
|
const settingsBtn = document.getElementById('auth-settings-btn');
|
||||||
|
if (settingsBtn && settingsBtn.parentElement) {
|
||||||
|
return createDropdown(settingsBtn.parentElement);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDropdown(targetContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDropdown(container) {
|
||||||
|
// Check if selector already exists
|
||||||
|
if (document.getElementById('dc-lang-selector')) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.id = 'dc-lang-selector';
|
||||||
|
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'dc-lang-btn';
|
||||||
|
btn.className = 'lang-selector-btn';
|
||||||
|
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
|
||||||
|
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
|
||||||
|
btn.title = 'Select Language';
|
||||||
|
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.id = 'dc-lang-dropdown';
|
||||||
|
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 160px; max-height: 320px; overflow-y: auto;';
|
||||||
|
|
||||||
|
SUPPORTED_LANGS.forEach(lang => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'lang-option';
|
||||||
|
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
|
||||||
|
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
|
||||||
|
item.onmouseleave = () => item.style.background = 'transparent';
|
||||||
|
|
||||||
|
const flag = document.createElement('span');
|
||||||
|
flag.textContent = lang === currentLang ? '✓' : '';
|
||||||
|
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.textContent = LANG_NAMES[lang];
|
||||||
|
|
||||||
|
item.appendChild(flag);
|
||||||
|
item.appendChild(name);
|
||||||
|
item.onclick = () => {
|
||||||
|
setLanguage(lang);
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
// Update button text
|
||||||
|
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
|
||||||
|
// Update checkmarks
|
||||||
|
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
|
||||||
|
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
|
||||||
|
});
|
||||||
|
// Show notification
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!wrapper.contains(e.target)) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
container.insertBefore(wrapper, container.firstChild);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
function init() {
|
||||||
|
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
|
||||||
|
const isRtl = RTL_LANGS.has(currentLang);
|
||||||
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||||
|
document.documentElement.lang = currentLang;
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
createLanguageSelector();
|
||||||
|
if (currentLang !== DEFAULT_LANG) {
|
||||||
|
const reqId = ++_langRequestId;
|
||||||
|
loadTranslations(currentLang, reqId).then(() => {
|
||||||
|
if (reqId === _langRequestId) applyTranslations();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose globally
|
||||||
|
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||||
|
|
||||||
|
// Auto-init
|
||||||
|
init();
|
||||||
|
})();
|
||||||
+180
-15
@@ -2,12 +2,13 @@
|
|||||||
* DC-077: i18n Language Selector
|
* DC-077: i18n Language Selector
|
||||||
*
|
*
|
||||||
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
||||||
* the dashboard language between en / es / zh / ar / de.
|
* the dashboard language. Supports all 31 backend languages with a searchable list.
|
||||||
*
|
*
|
||||||
* - Shows current language with flag emoji
|
* - Shows current language with flag emoji
|
||||||
* - Persists selection to localStorage('dashcaddy-language')
|
* - Persists selection to localStorage('dashcaddy-language')
|
||||||
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
||||||
* - Reloads the page on change so the new language takes effect
|
* - Reloads the page on change so the new language takes effect
|
||||||
|
* - Search filter for quickly finding a language in the 31-option list
|
||||||
*
|
*
|
||||||
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||||
*/
|
*/
|
||||||
@@ -18,11 +19,37 @@
|
|||||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', flag: '🇺🇸', label: 'English' },
|
{ code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
|
||||||
{ code: 'es', flag: '🇪🇸', label: 'Español' },
|
{ code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
|
||||||
{ code: 'zh', flag: '🇨🇳', label: '中文' },
|
{ code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
|
||||||
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
|
{ code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
|
||||||
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
|
{ code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' },
|
||||||
|
{ code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' },
|
||||||
|
{ code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' },
|
||||||
|
{ code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' },
|
||||||
|
{ code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' },
|
||||||
|
{ code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' },
|
||||||
|
{ code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' },
|
||||||
|
{ code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' },
|
||||||
|
{ code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' },
|
||||||
|
{ code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' },
|
||||||
|
{ code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' },
|
||||||
|
{ code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' },
|
||||||
|
{ code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' },
|
||||||
|
{ code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' },
|
||||||
|
{ code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' },
|
||||||
|
{ code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' },
|
||||||
|
{ code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' },
|
||||||
|
{ code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' },
|
||||||
|
{ code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' },
|
||||||
|
{ code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' },
|
||||||
|
{ code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' },
|
||||||
|
{ code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' },
|
||||||
|
{ code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' },
|
||||||
|
{ code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' },
|
||||||
|
{ code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' },
|
||||||
|
{ code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' },
|
||||||
|
{ code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUPPORTED = LANGUAGES.map(l => l.code);
|
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||||
@@ -82,7 +109,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
right: 0;
|
right: 0;
|
||||||
min-width: 160px;
|
min-width: 200px;
|
||||||
|
max-height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--card-base, #1e1e2e);
|
background: var(--card-base, #1e1e2e);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -92,7 +122,35 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.dc-lang-menu.open {
|
.dc-lang-menu.open {
|
||||||
display: block;
|
display: flex;
|
||||||
|
}
|
||||||
|
.dc-lang-search {
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-base, #111);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.dc-lang-search:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.dc-lang-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 280px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.dc-lang-list::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
.dc-lang-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.dc-lang-option {
|
.dc-lang-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -126,6 +184,11 @@
|
|||||||
.dc-lang-option.active .dc-lang-check {
|
.dc-lang-option.active .dc-lang-check {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.dc-lang-option.dc-lang-focus,
|
||||||
|
.dc-lang-option:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
.dc-lang-label-sm {
|
.dc-lang-label-sm {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@@ -146,19 +209,111 @@
|
|||||||
menu.className = 'dc-lang-menu';
|
menu.className = 'dc-lang-menu';
|
||||||
menu.setAttribute('role', 'menu');
|
menu.setAttribute('role', 'menu');
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
const search = document.createElement('input');
|
||||||
|
search.type = 'text';
|
||||||
|
search.className = 'dc-lang-search';
|
||||||
|
search.placeholder = 'Search language…';
|
||||||
|
search.setAttribute('aria-label', 'Search languages');
|
||||||
|
search.autocomplete = 'off';
|
||||||
|
|
||||||
|
// Scrollable option list
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'dc-lang-list';
|
||||||
|
|
||||||
for (const lang of LANGUAGES) {
|
for (const lang of LANGUAGES) {
|
||||||
const opt = document.createElement('div');
|
const opt = document.createElement('div');
|
||||||
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
||||||
opt.setAttribute('role', 'menuitemradio');
|
opt.setAttribute('role', 'menuitemradio');
|
||||||
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
||||||
|
opt.setAttribute('tabindex', '-1');
|
||||||
opt.dataset.lang = lang.code;
|
opt.dataset.lang = lang.code;
|
||||||
|
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
|
||||||
opt.innerHTML =
|
opt.innerHTML =
|
||||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
||||||
'<span class="dc-lang-name">' + lang.label + '</span>' +
|
'<span class="dc-lang-name">' + lang.nativeLabel +
|
||||||
|
'<span style="opacity:0.5;font-size:0.8em;margin-left:6px;">' + lang.label + '</span>' +
|
||||||
|
'</span>' +
|
||||||
'<span class="dc-lang-check">✓</span>';
|
'<span class="dc-lang-check">✓</span>';
|
||||||
menu.appendChild(opt);
|
list.appendChild(opt);
|
||||||
}
|
}
|
||||||
return menu;
|
|
||||||
|
// Filter logic — extracted so we can reset from the open handler
|
||||||
|
function applyFilter(query) {
|
||||||
|
var q = (query || '').toLowerCase().trim();
|
||||||
|
list.querySelectorAll('.dc-lang-option').forEach(function (opt) {
|
||||||
|
var match = !q || opt.dataset.search.indexOf(q) !== -1;
|
||||||
|
opt.style.display = match ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', function () {
|
||||||
|
applyFilter(this.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent clicks on search from closing the menu
|
||||||
|
search.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
|
||||||
|
// Expose reset so init() can restore visibility when reopening
|
||||||
|
menu._resetFilter = function () {
|
||||||
|
search.value = '';
|
||||||
|
applyFilter('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Keyboard navigation (Arrow Up/Down, Enter, Space) =====
|
||||||
|
function getVisibleOptions() {
|
||||||
|
return Array.from(list.querySelectorAll('.dc-lang-option')).filter(
|
||||||
|
function (o) { return o.style.display !== 'none'; }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusOption(opt) {
|
||||||
|
if (!opt) return;
|
||||||
|
var visible = getVisibleOptions();
|
||||||
|
visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); });
|
||||||
|
opt.classList.add('dc-lang-focus');
|
||||||
|
opt.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('keydown', function (e) {
|
||||||
|
var visible = getVisibleOptions();
|
||||||
|
if (visible.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
focusOption(visible[0]);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
var active = list.querySelector('.dc-lang-option.active');
|
||||||
|
if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('keydown', function (e) {
|
||||||
|
var visible = getVisibleOptions();
|
||||||
|
var currentIdx = visible.indexOf(document.activeElement);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
var next = visible[Math.min(currentIdx + 1, visible.length - 1)];
|
||||||
|
focusOption(next);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentIdx === 0) {
|
||||||
|
search.focus();
|
||||||
|
} else {
|
||||||
|
focusOption(visible[currentIdx - 1]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
var opt = document.activeElement;
|
||||||
|
if (opt && opt.classList.contains('dc-lang-option')) {
|
||||||
|
selectLanguage(opt.dataset.lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.appendChild(search);
|
||||||
|
menu.appendChild(list);
|
||||||
|
return { menu: menu, search: search };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectLanguage(code) {
|
async function selectLanguage(code) {
|
||||||
@@ -207,7 +362,9 @@
|
|||||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||||
'<span class="dc-lang-caret">▼</span>';
|
'<span class="dc-lang-caret">▼</span>';
|
||||||
|
|
||||||
const menu = buildMenu(current);
|
const built = buildMenu(current);
|
||||||
|
const menu = built.menu;
|
||||||
|
const searchInput = built.search;
|
||||||
|
|
||||||
// Small label beneath, matching the "Customize Theme" link style
|
// Small label beneath, matching the "Customize Theme" link style
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
@@ -223,9 +380,16 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isOpen = menu.classList.toggle('open');
|
const isOpen = menu.classList.toggle('open');
|
||||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
if (isOpen) {
|
||||||
|
// Reset filter: clear search text AND restore all hidden options
|
||||||
|
if (typeof menu._resetFilter === 'function') {
|
||||||
|
menu._resetFilter();
|
||||||
|
}
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Option clicks
|
// Option clicks (delegate to the list container)
|
||||||
menu.addEventListener('click', (e) => {
|
menu.addEventListener('click', (e) => {
|
||||||
const opt = e.target.closest('.dc-lang-option');
|
const opt = e.target.closest('.dc-lang-option');
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
@@ -243,11 +407,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on Escape
|
// Close on Escape — return focus to the trigger button for accessibility
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||||
menu.classList.remove('open');
|
menu.classList.remove('open');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -377,8 +377,26 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finish setup button
|
// Summary "Continue →" — advance to the disk-safety warning step
|
||||||
const finishBtn = document.getElementById('setup-finish');
|
const summaryNext = document.getElementById('setup-summary-next');
|
||||||
|
if (summaryNext) {
|
||||||
|
summaryNext.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showStep('setup-step-disk-safety');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk-safety step navigation
|
||||||
|
const diskSafetyBack = document.getElementById('setup-disk-safety-back');
|
||||||
|
if (diskSafetyBack) {
|
||||||
|
diskSafetyBack.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showStep('setup-step-summary');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish setup button (now on the disk-safety step)
|
||||||
|
const finishBtn = document.getElementById('setup-disk-safety-finish');
|
||||||
if (finishBtn) {
|
if (finishBtn) {
|
||||||
finishBtn.onclick = function(e) {
|
finishBtn.onclick = function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user