feat: Discord-style glass morphism UI redesign + nightly CI/CD

- App shell: gradient title, glass admin modal, avatar, admin login/logout
- All plugin empty states: floating icon animations, updated typography
- Soundboard: orange accent theme replacing blurple default
- Global styles: glass morphism variables, Discord-dark color palette
- CI/CD: nightly deploy (stops main, starts nightly on port 8085) + manual restore-main job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 02:12:02 +01:00
parent ecd5e96ee2
commit 8abe0775a5
8 changed files with 556 additions and 94 deletions

View file

@ -171,6 +171,96 @@ deploy:
"$DEPLOY_IMAGE" "$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}" - docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
deploy-nightly:
stage: deploy
image: docker:latest
needs: [docker-build]
rules:
- if: $CI_COMMIT_BRANCH == "nightly"
variables:
DEPLOY_IMAGE: "$INTERNAL_REGISTRY/root/gaming-hub:nightly"
CONTAINER_NAME: "gaming-hub-nightly"
script:
- echo "[Nightly Deploy] Logging into registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login "$INTERNAL_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- echo "[Nightly Deploy] Pulling $DEPLOY_IMAGE..."
- docker pull "$DEPLOY_IMAGE"
- echo "[Nightly Deploy] Stopping main container..."
- docker stop gaming-hub || true
- docker rm gaming-hub || true
- echo "[Nightly Deploy] Stopping old nightly container..."
- docker stop "$CONTAINER_NAME" || true
- docker rm "$CONTAINER_NAME" || true
- echo "[Nightly Deploy] Starting $CONTAINER_NAME..."
- |
docker run -d \
--name "$CONTAINER_NAME" \
--network pangolin \
--restart unless-stopped \
--label "channel=nightly" \
-p 8085:8080 \
-e TZ=Europe/Berlin \
-e NODE_ENV=production \
-e PORT=8080 \
-e DATA_DIR=/data \
-e SOUNDS_DIR=/data/sounds \
-e "NODE_OPTIONS=--dns-result-order=ipv4first" \
-e ADMIN_PWD="$GAMING_HUB_ADMIN_PWD" \
-e PCM_CACHE_MAX_MB=2048 \
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
-e DISCORD_TOKEN_NOTIFICATIONS="$GAMING_HUB_DISCORD_NOTIFICATIONS" \
-e PUBLIC_URL="$GAMING_HUB_PUBLIC_URL" \
-e STEAM_API_KEY="$STEAM_API_KEY" \
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
"$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
restore-main:
stage: deploy
image: docker:latest
needs: [docker-build]
rules:
- if: $CI_COMMIT_BRANCH == "nightly"
when: manual
allow_failure: true
variables:
DEPLOY_IMAGE: "$INTERNAL_REGISTRY/root/gaming-hub:latest"
CONTAINER_NAME: "gaming-hub"
script:
- echo "[Restore Main] Logging into registry..."
- echo "$CI_REGISTRY_PASSWORD" | docker login "$INTERNAL_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- echo "[Restore Main] Stopping nightly container..."
- docker stop gaming-hub-nightly || true
- docker rm gaming-hub-nightly || true
- echo "[Restore Main] Pulling $DEPLOY_IMAGE..."
- docker pull "$DEPLOY_IMAGE"
- echo "[Restore Main] Starting $CONTAINER_NAME..."
- |
docker run -d \
--name "$CONTAINER_NAME" \
--network pangolin \
--restart unless-stopped \
-p 8085:8080 \
-e TZ=Europe/Berlin \
-e NODE_ENV=production \
-e PORT=8080 \
-e DATA_DIR=/data \
-e SOUNDS_DIR=/data/sounds \
-e "NODE_OPTIONS=--dns-result-order=ipv4first" \
-e ADMIN_PWD="$GAMING_HUB_ADMIN_PWD" \
-e PCM_CACHE_MAX_MB=2048 \
-e DISCORD_TOKEN_JUKEBOX="$GAMING_HUB_DISCORD_JUKEBOX" \
-e DISCORD_TOKEN_RADIO="$GAMING_HUB_DISCORD_RADIO" \
-e DISCORD_TOKEN_NOTIFICATIONS="$GAMING_HUB_DISCORD_NOTIFICATIONS" \
-e PUBLIC_URL="$GAMING_HUB_PUBLIC_URL" \
-e STEAM_API_KEY="$STEAM_API_KEY" \
-v /mnt/cache/appdata/gaming-hub/data:/data:rw \
-v /mnt/cache/appdata/dockge/container/jukebox/sounds/:/data/sounds:rw \
"$DEPLOY_IMAGE"
- docker ps --filter name="$CONTAINER_NAME" --format "ID={{.ID}} Status={{.Status}} Image={{.Image}}"
bump-version: bump-version:
stage: bump-version stage: bump-version
image: image:

View file

@ -40,6 +40,12 @@ export default function App() {
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({}); const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Admin state
const [adminLoggedIn, setAdminLoggedIn] = useState(false);
const [showAdminModal, setShowAdminModal] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [adminError, setAdminError] = useState('');
// Electron auto-update state // Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron; const isElectron = !!(window as any).electronAPI?.isElectron;
const electronVersion = isElectron ? (window as any).electronAPI.version : null; const electronVersion = isElectron ? (window as any).electronAPI.version : null;
@ -130,13 +136,43 @@ export default function App() {
const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev'; const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev';
// Close version modal on Escape // Close modals on Escape
useEffect(() => { useEffect(() => {
if (!showVersionModal) return; if (!showVersionModal && !showAdminModal) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); }; const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowVersionModal(false);
setShowAdminModal(false);
}
};
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [showVersionModal]); }, [showVersionModal, showAdminModal]);
// Admin login handler
const handleAdminLogin = () => {
if (!adminPassword) return;
fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPassword }),
})
.then(r => {
if (r.ok) {
setAdminLoggedIn(true);
setAdminPassword('');
setAdminError('');
} else {
setAdminError('Falsches Passwort');
}
})
.catch(() => setAdminError('Verbindungsfehler'));
};
const handleAdminLogout = () => {
setAdminLoggedIn(false);
setShowAdminModal(false);
};
// Tab icon mapping // Tab icon mapping
@ -198,7 +234,6 @@ export default function App() {
<span <span
className="hub-version hub-version-clickable" className="hub-version hub-version-clickable"
onClick={() => { onClick={() => {
// Status vom Main-Prozess synchronisieren bevor Modal öffnet
if (isElectron) { if (isElectron) {
const api = (window as any).electronAPI; const api = (window as any).electronAPI;
const s = api.getUpdateStatus?.(); const s = api.getUpdateStatus?.();
@ -212,6 +247,15 @@ export default function App() {
> >
v{version} v{version}
</span> </span>
<button
className={`hub-admin-btn ${adminLoggedIn ? 'logged-in' : ''}`}
onClick={() => setShowAdminModal(true)}
title="Admin Login"
>
{'\u{1F511}'}
{adminLoggedIn && <span className="hub-admin-green-dot" />}
</button>
<div className="hub-avatar">DK</div>
</div> </div>
</header> </header>
@ -307,6 +351,46 @@ export default function App() {
</div> </div>
)} )}
{showAdminModal && (
<div className="hub-admin-overlay" onClick={() => setShowAdminModal(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
{adminLoggedIn ? (
<>
<div className="hub-admin-modal-title">Admin Panel</div>
<div className="hub-admin-modal-info">
<div className="hub-admin-modal-avatar">A</div>
<div className="hub-admin-modal-text">
<span className="hub-admin-modal-name">Administrator</span>
<span className="hub-admin-modal-role">Eingeloggt</span>
</div>
</div>
<button className="hub-admin-modal-logout" onClick={handleAdminLogout}>
Ausloggen
</button>
</>
) : (
<>
<div className="hub-admin-modal-title">{'\u{1F511}'} Admin Login</div>
<div className="hub-admin-modal-subtitle">Passwort eingeben um Einstellungen freizuschalten</div>
{adminError && <div className="hub-admin-modal-error">{adminError}</div>}
<input
className="hub-admin-modal-input"
type="password"
placeholder="Passwort"
value={adminPassword}
onChange={e => setAdminPassword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAdminLogin(); }}
autoFocus
/>
<button className="hub-admin-modal-login" onClick={handleAdminLogin}>
Login
</button>
</>
)}
</div>
</div>
)}
<main className="hub-content"> <main className="hub-content">
{plugins.length === 0 ? ( {plugins.length === 0 ? (
<div className="hub-empty"> <div className="hub-empty">

View file

@ -472,24 +472,30 @@
/* ── Empty state ── */ /* ── Empty state ── */
.gl-empty { .gl-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
padding: 40px; height: 100%;
} }
.gl-empty-icon { .gl-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 16px; filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
animation: gl-empty-float 3s ease-in-out infinite;
}
@keyframes gl-empty-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.gl-empty h3 { .gl-empty h3 {
color: var(--text-normal); font-size: 26px; font-weight: 700; color: #f2f3f5;
margin: 0 0 8px; letter-spacing: -0.5px; margin: 0;
} }
.gl-empty p { .gl-empty p {
color: var(--text-faint); font-size: 15px; color: #80848e;
margin: 0; text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
font-size: 14px;
} }
/* ── Common game playtime chips ── */ /* ── Common game playtime chips ── */

View file

@ -456,22 +456,26 @@
} }
.lol-empty { .lol-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-faint); padding: 40px; height: 100%;
} }
.lol-empty-icon { .lol-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
animation: lol-float 3s ease-in-out infinite;
}
@keyframes lol-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.lol-empty h3 { .lol-empty h3 {
margin: 0 0 8px; font-size: 26px; font-weight: 700; color: #f2f3f5;
color: var(--text-muted); letter-spacing: -0.5px; margin: 0;
font-size: 16px;
} }
.lol-empty p { .lol-empty p {
margin: 0; font-size: 15px; color: #80848e;
font-size: 13px; text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Load more ── */ /* ── Load more ── */

View file

@ -3,7 +3,7 @@
/* Soundboard Plugin — ported from Jukebox styles */ /* Soundboard Plugin — ported from Jukebox styles */
/* /*
Theme Variables Default (Discord Blurple) Theme Variables Default (Orange Accent)
*/ */
.sb-app { .sb-app {
--bg-deep: #1a1b1e; --bg-deep: #1a1b1e;
@ -18,10 +18,10 @@
--text-muted: #949ba4; --text-muted: #949ba4;
--text-faint: #6d6f78; --text-faint: #6d6f78;
--accent: #5865f2; --accent: #e67e22;
--accent-rgb: 88, 101, 242; --accent-rgb: 230, 126, 34;
--accent-hover: #4752c4; --accent-hover: #d35400;
--accent-glow: rgba(88, 101, 242, .45); --accent-glow: rgba(230, 126, 34, .45);
--green: #23a55a; --green: #23a55a;
--red: #f23f42; --red: #f23f42;
@ -91,6 +91,18 @@
--accent-glow: rgba(52, 152, 219, .4); --accent-glow: rgba(52, 152, 219, .4);
} }
/* ── Theme: Cherry ── */
.sb-app[data-theme="cherry"] {
--bg-deep: #1a0f14;
--bg-primary: #221419;
--bg-secondary: #2f1c22;
--bg-tertiary: #3d242c;
--accent: #e74c3c;
--accent-rgb: 231, 76, 60;
--accent-hover: #c0392b;
--accent-glow: rgba(231, 76, 60, .4);
}
/* /*
App Layout App Layout
*/ */
@ -310,6 +322,33 @@
margin-left: 2px; margin-left: 2px;
} }
/* ── Disconnect Button ── */
.disconnect-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
background: rgba(242, 63, 66, .1);
border: 1px solid rgba(242, 63, 66, .2);
backdrop-filter: blur(8px);
color: var(--red);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
}
.disconnect-btn:hover {
background: rgba(242, 63, 66, .2);
border-color: rgba(242, 63, 66, .4);
box-shadow: 0 0 12px rgba(242, 63, 66, .15);
}
.disconnect-btn:active {
transform: scale(0.97);
}
/* ── Connection Details Modal ── */ /* ── Connection Details Modal ── */
.conn-modal-overlay { .conn-modal-overlay {
position: fixed; position: fixed;
@ -340,9 +379,10 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 14px 16px; padding: 14px 16px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(255,255,255,.06);
font-weight: 700;
font-size: 14px; font-size: 14px;
font-weight: 700;
color: var(--text-normal);
} }
.conn-modal-close { .conn-modal-close {
margin-left: auto; margin-left: auto;
@ -438,7 +478,9 @@
gap: 6px; gap: 6px;
padding: 6px 14px; padding: 6px 14px;
border-radius: 20px; border-radius: 20px;
background: var(--bg-tertiary); background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .08);
backdrop-filter: blur(8px);
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font); font-family: var(--font);
font-size: 13px; font-size: 13px;
@ -449,13 +491,16 @@
} }
.cat-tab:hover { .cat-tab:hover {
background: var(--bg-modifier-selected); background: rgba(255, 255, 255, .1);
border-color: rgba(255, 255, 255, .15);
color: var(--text-normal); color: var(--text-normal);
} }
.cat-tab.active { .cat-tab.active {
background: var(--accent); background: var(--accent);
color: var(--white); color: var(--white);
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(var(--accent-rgb), .35);
} }
.tab-count { .tab-count {
@ -489,9 +534,9 @@
width: 100%; width: 100%;
height: 32px; height: 32px;
padding: 0 28px 0 32px; padding: 0 28px 0 32px;
border: 1px solid rgba(255, 255, 255, .06); border: 1px solid rgba(255, 255, 255, .08);
border-radius: 20px; border-radius: 20px;
background: var(--bg-secondary); background: var(--bg-deep);
color: var(--text-normal); color: var(--text-normal);
font-family: var(--font); font-family: var(--font);
font-size: 13px; font-size: 13px;
@ -572,8 +617,8 @@
height: 24px; height: 24px;
padding: 0 10px; padding: 0 10px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45); border: 1px solid rgba(var(--accent-rgb), .45);
background: rgba(var(--accent-rgb, 88, 101, 242), .12); background: rgba(var(--accent-rgb), .12);
color: var(--accent); color: var(--accent);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
@ -639,7 +684,7 @@
} }
.tb-btn.random { .tb-btn.random {
border-color: rgba(88, 101, 242, .3); border-color: rgba(230, 126, 34, .3);
color: var(--accent); color: var(--accent);
} }
@ -834,7 +879,7 @@
gap: 4px; gap: 4px;
padding: 3px 8px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
background: rgba(var(--accent-rgb, 88, 101, 242), .15); background: rgba(var(--accent-rgb), .15);
color: var(--accent); color: var(--accent);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@ -875,8 +920,9 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
background: var(--bg-secondary); background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .06); border: 1px solid rgba(255, 255, 255, .08);
backdrop-filter: blur(8px);
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all var(--transition);
@ -884,13 +930,16 @@
} }
.cat-chip:hover { .cat-chip:hover {
border-color: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .15);
color: var(--text-normal); color: var(--text-normal);
background: var(--bg-tertiary); background: rgba(255, 255, 255, .1);
} }
.cat-chip.active { .cat-chip.active {
background: rgba(88, 101, 242, .1); background: var(--accent);
color: var(--white);
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(var(--accent-rgb), .35);
} }
.cat-dot { .cat-dot {
@ -924,7 +973,7 @@
} }
/* /*
Sound Card Sound Card Glass Morphism
*/ */
.sound-card { .sound-card {
position: relative; position: relative;
@ -934,11 +983,13 @@
justify-content: center; justify-content: center;
gap: 3px; gap: 3px;
padding: 12px 6px 8px; padding: 12px 6px 8px;
background: var(--bg-secondary); background: rgba(255, 255, 255, .05);
border: 1px solid rgba(255, 255, 255, .08);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all var(--transition);
border: 2px solid transparent;
user-select: none; user-select: none;
overflow: hidden; overflow: hidden;
aspect-ratio: 1; aspect-ratio: 1;
@ -953,15 +1004,14 @@
border-radius: inherit; border-radius: inherit;
opacity: 0; opacity: 0;
transition: opacity var(--transition); transition: opacity var(--transition);
background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); background: radial-gradient(circle at 50% 0%, rgba(var(--accent-rgb), .12), transparent 70%);
pointer-events: none; pointer-events: none;
} }
.sound-card:hover { .sound-card:hover {
background: var(--bg-tertiary); transform: scale(1.05);
transform: translateY(-3px); border-color: rgba(var(--accent-rgb), .35);
box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow); box-shadow: 0 4px 20px rgba(var(--accent-rgb), .15);
border-color: rgba(88, 101, 242, .2);
} }
.sound-card:hover::before { .sound-card:hover::before {
@ -969,7 +1019,7 @@
} }
.sound-card:active { .sound-card:active {
transform: translateY(0); transform: scale(0.97);
transition-duration: 50ms; transition-duration: 50ms;
} }
@ -992,7 +1042,7 @@
.ripple { .ripple {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
background: rgba(88, 101, 242, .3); background: rgba(var(--accent-rgb), .3);
transform: scale(0); transform: scale(0);
animation: ripple-expand 500ms ease-out forwards; animation: ripple-expand 500ms ease-out forwards;
pointer-events: none; pointer-events: none;
@ -1171,8 +1221,8 @@
gap: 6px; gap: 6px;
padding: 4px 12px; padding: 4px 12px;
border-radius: 20px; border-radius: 20px;
background: rgba(var(--accent-rgb, 88, 101, 242), .12); background: rgba(var(--accent-rgb), .12);
border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); border: 1px solid rgba(var(--accent-rgb), .2);
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
max-width: none; max-width: none;
@ -1251,23 +1301,26 @@
.vol-slider::-webkit-slider-thumb { .vol-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
width: 12px; width: 14px;
height: 12px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), .5);
cursor: pointer; cursor: pointer;
transition: transform var(--transition); transition: transform var(--transition), box-shadow var(--transition);
} }
.vol-slider::-webkit-slider-thumb:hover { .vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.3); transform: scale(1.3);
box-shadow: 0 0 14px rgba(var(--accent-rgb), .7);
} }
.vol-slider::-moz-range-thumb { .vol-slider::-moz-range-thumb {
width: 12px; width: 14px;
height: 12px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), .5);
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
@ -2042,7 +2095,6 @@
z-index: 300; z-index: 300;
animation: fade-in 150ms ease; animation: fade-in 150ms ease;
} }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
.dl-modal { .dl-modal {
width: 420px; max-width: 92vw; width: 420px; max-width: 92vw;

View file

@ -356,23 +356,26 @@
/* ── Empty state ── */ /* ── Empty state ── */
.stream-empty { .stream-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-muted); padding: 40px; height: 100%;
} }
.stream-empty-icon { .stream-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
opacity: 0.4; animation: stream-float 3s ease-in-out infinite;
}
@keyframes stream-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.stream-empty h3 { .stream-empty h3 {
font-size: 18px; font-size: 26px; font-weight: 700; color: #f2f3f5;
font-weight: 600; letter-spacing: -0.5px; margin: 0;
color: var(--text-normal);
margin-bottom: 6px;
} }
.stream-empty p { .stream-empty p {
font-size: 14px; font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Error ── */ /* ── Error ── */

View file

@ -161,23 +161,26 @@
/* ── Empty state ── */ /* ── Empty state ── */
.wt-empty { .wt-empty {
text-align: center; flex: 1; display: flex; flex-direction: column;
padding: 60px 20px; align-items: center; justify-content: center; gap: 16px;
color: var(--text-muted); padding: 40px; height: 100%;
} }
.wt-empty-icon { .wt-empty-icon {
font-size: 48px; font-size: 64px; line-height: 1;
margin-bottom: 12px; filter: drop-shadow(0 0 20px rgba(230,126,34,0.5));
opacity: 0.4; animation: wt-float 3s ease-in-out infinite;
}
@keyframes wt-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
} }
.wt-empty h3 { .wt-empty h3 {
font-size: 18px; font-size: 26px; font-weight: 700; color: #f2f3f5;
font-weight: 600; letter-spacing: -0.5px; margin: 0;
color: var(--text-normal);
margin-bottom: 6px;
} }
.wt-empty p { .wt-empty p {
font-size: 14px; font-size: 15px; color: #80848e;
text-align: center; max-width: 360px; line-height: 1.5; margin: 0;
} }
/* ── Error ── */ /* ── Error ── */

View file

@ -4,19 +4,27 @@
--bg-primary: #1e1f22; --bg-primary: #1e1f22;
--bg-secondary: #2b2d31; --bg-secondary: #2b2d31;
--bg-tertiary: #313338; --bg-tertiary: #313338;
--text-normal: #dbdee1; --bg-light: #3a3c41;
--text-muted: #949ba4; --bg-lighter: #43464d;
--text-faint: #6d6f78; --text-normal: #f2f3f5;
--text-muted: #b5bac1;
--text-faint: #80848e;
--accent: #e67e22; --accent: #e67e22;
--accent-rgb: 230, 126, 34; --accent-rgb: 230, 126, 34;
--accent-hover: #d35400; --accent-hover: #d35400;
--success: #57d28f; --accent-glow: rgba(230, 126, 34, 0.5);
--accent-dim: rgba(230, 126, 34, 0.35);
--accent-subtle: rgba(230, 126, 34, 0.12);
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-bg-hover: rgba(255, 255, 255, 0.09);
--success: #23a559;
--danger: #ed4245; --danger: #ed4245;
--warning: #fee75c; --warning: #fee75c;
--border: rgba(255, 255, 255, 0.06); --border: rgba(255, 255, 255, 0.06);
--radius: 8px; --radius: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--transition: 150ms ease; --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--font: 'Segoe UI', system-ui, -apple-system, sans-serif; --font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--header-height: 56px; --header-height: 56px;
} }
@ -78,14 +86,18 @@ html, body {
.hub-logo { .hub-logo {
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 0 6px var(--accent-glow));
} }
.hub-title { .hub-title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--text-normal);
letter-spacing: -0.02em; letter-spacing: -0.02em;
white-space: nowrap; white-space: nowrap;
background: linear-gradient(135deg, var(--accent), #f39c12);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
/* ── Connection Status Dot ── */ /* ── Connection Status Dot ── */
@ -155,8 +167,8 @@ html, body {
} }
.hub-tab.active { .hub-tab.active {
color: var(--accent); color: var(--text-normal);
background: rgba(var(--accent-rgb), 0.1); background: var(--accent-subtle);
} }
.hub-tab.active::after { .hub-tab.active::after {
@ -165,10 +177,11 @@ html, body {
bottom: -1px; bottom: -1px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: calc(100% - 16px); width: 70%;
height: 2px; height: 2px;
background: var(--accent); background: var(--accent);
border-radius: 1px; border-radius: 2px;
box-shadow: 0 0 8px var(--accent-glow), 0 0 16px rgba(230,126,34,0.2);
} }
.hub-tab-icon { .hub-tab-icon {
@ -616,6 +629,213 @@ html, body {
color: var(--text-normal); color: var(--text-normal);
} }
/* ── Admin Button ── */
.hub-admin-btn {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-muted);
font-size: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.hub-admin-btn:hover {
background: var(--glass-bg-hover);
border-color: var(--accent-dim);
box-shadow: 0 0 12px rgba(230,126,34,0.15);
}
.hub-admin-btn.logged-in {
border-color: var(--success);
}
.hub-admin-green-dot {
position: absolute;
top: 1px;
right: 1px;
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
border: 2px solid var(--bg-primary);
}
/* ── Avatar ── */
.hub-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #f39c12);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #fff;
box-shadow: 0 0 0 2px var(--bg-primary);
}
/* ── Admin Modal ── */
.hub-admin-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: hub-modal-fade-in 0.2s ease;
}
@keyframes hub-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.hub-admin-modal {
background: var(--bg-secondary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 32px;
width: 360px;
max-width: 90vw;
box-shadow: 0 16px 48px rgba(0,0,0,0.5), 0 0 40px rgba(230,126,34,0.08);
animation: hub-modal-slide-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes hub-modal-slide-in {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.hub-admin-modal-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.hub-admin-modal-subtitle {
font-size: 13px;
color: var(--text-faint);
margin-bottom: 24px;
}
.hub-admin-modal-error {
font-size: 13px;
color: var(--danger);
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(237, 66, 69, 0.1);
border-radius: var(--radius);
}
.hub-admin-modal-input {
width: 100%;
background: var(--bg-deep);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
color: var(--text-normal);
font-family: var(--font);
font-size: 14px;
padding: 10px 14px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
margin-bottom: 16px;
}
.hub-admin-modal-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(230,126,34,0.12);
}
.hub-admin-modal-login {
width: 100%;
background: var(--accent);
border: none;
border-radius: var(--radius);
color: #fff;
font-family: var(--font);
font-size: 14px;
font-weight: 600;
padding: 10px;
cursor: pointer;
transition: all var(--transition);
box-shadow: 0 2px 10px var(--accent-dim);
}
.hub-admin-modal-login:hover {
background: var(--accent-hover);
box-shadow: 0 4px 16px var(--accent-glow);
}
.hub-admin-modal-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.hub-admin-modal-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #f39c12);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: #fff;
box-shadow: 0 0 12px var(--accent-dim);
}
.hub-admin-modal-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.hub-admin-modal-name {
font-weight: 600;
font-size: 15px;
}
.hub-admin-modal-role {
font-size: 12px;
color: var(--success);
font-weight: 500;
}
.hub-admin-modal-logout {
width: 100%;
background: rgba(237,66,69,0.12);
border: 1px solid rgba(237,66,69,0.25);
border-radius: var(--radius);
color: var(--danger);
font-family: var(--font);
font-size: 14px;
font-weight: 500;
padding: 10px;
cursor: pointer;
transition: all var(--transition);
}
.hub-admin-modal-logout:hover {
background: rgba(237,66,69,0.2);
}
/* ── Focus Styles ── */ /* ── Focus Styles ── */
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);