feat: theme system with persistent selection and Dark Ocean starter theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-28 10:10:44 +02:00
1 parent 1bb8494fdb
commit c046aea776
5 files changed
+115 -9

No files matched your search

+11 -4
View File
@@ -324,7 +324,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>
<input id="spotify-link-input" type="url" placeholder="Spotify Link einfügen (Track, Album oder Playlist)…" style="font-size:15px" oninput="clearSpotifyPreview()"> <input id="spotify-link-input" type="url" placeholder="Spotify Link einfügen (Track, Album oder Playlist)…" style="font-size:15px" oninput="clearSpotifyPreview()">
</div> </div>
<button class="btn-outline" style="padding:0 20px;font-size:14px;font-weight:700;white-space:nowrap;flex-shrink:0;background:linear-gradient(135deg,var(--accent),#c07010);color:#000;border-color:transparent" onclick="resolveSpotifyLink()">Importieren</button> <button class="btn-outline" style="padding:0 20px;font-size:14px;font-weight:700;white-space:nowrap;flex-shrink:0;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000;border-color:transparent" onclick="resolveSpotifyLink()">Importieren</button>
</div> </div>
</div> </div>
<div id="spotify-importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;font-size:13px;color:var(--text2);line-height:1.8"> <div id="spotify-importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;font-size:13px;color:var(--text2);line-height:1.8">
@@ -464,7 +464,7 @@
<!-- Sticky header --> <!-- Sticky header -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;border-bottom:1px solid var(--border);flex-shrink:0"> <div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;border-bottom:1px solid var(--border);flex-shrink:0">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<div style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--accent),#c07010);display:flex;align-items:center;justify-content:center;font-size:17px;box-shadow:0 4px 12px var(--accent-glow)">⚙</div> <div style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:17px;box-shadow:0 4px 12px var(--accent-glow)">⚙</div>
<div> <div>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px">Einstellungen</div> <div style="font-size:17px;font-weight:800;letter-spacing:-.3px">Einstellungen</div>
<div style="font-size:11px;color:var(--text2);margin-top:1px">NovaSonic</div> <div style="font-size:11px;color:var(--text2);margin-top:1px">NovaSonic</div>
@@ -509,11 +509,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🎨</span>
Theme
</h3>
<div id="theme-picker" class="theme-picker"></div>
</div>
</div> </div>
<!-- Sticky footer --> <!-- Sticky footer -->
<div style="display:flex;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px"> <div style="display:flex;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px">
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:var(--surface2);color:var(--text2)" onclick="closeSettings()">Abbrechen</button> <button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:var(--surface2);color:var(--text2)" onclick="closeSettings()">Abbrechen</button>
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),#c07010);color:#000" onclick="saveSettings()">Speichern</button> <button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
</div> </div>
</div> </div>
</div> </div>
@@ -537,7 +544,7 @@
<input class="pl-input" id="pl-new-name" placeholder="Neue Playlist erstellen…" type="text"> <input class="pl-input" id="pl-new-name" placeholder="Neue Playlist erstellen…" type="text">
<div class="pl-btns"> <div class="pl-btns">
<button style="background:var(--surface2);color:var(--text2)" onclick="closePlModal()">Abbrechen</button> <button style="background:var(--surface2);color:var(--text2)" onclick="closePlModal()">Abbrechen</button>
<button style="background:linear-gradient(135deg,var(--accent),#c07010);color:#000" onclick="createAndAddPlaylist()">Erstellen & Hinzufügen</button> <button style="background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="createAndAddPlaylist()">Erstellen & Hinzufügen</button>
</div> </div>
</div> </div>
</div> </div>
+5 -1
View File
@@ -2,6 +2,7 @@ import { S } from './state.js';
import { toast } from './utils.js'; import { toast } from './utils.js';
import { api } from './api.js'; import { api } from './api.js';
import { injectPWA } from './pwa.js'; import { injectPWA } from './pwa.js';
import { loadSavedTheme } from './themes.js';
import { import {
playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded, playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded,
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
@@ -31,6 +32,7 @@ import {
deletePlaylist, _confirmOk, _confirmCancel, deletePlaylist, _confirmOk, _confirmCancel,
toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
_pickTheme,
} from './ui.js'; } from './ui.js';
// ─── CONNECT ─── // ─── CONNECT ───
@@ -92,6 +94,8 @@ async function startApp() {
// ─── INIT ─── // ─── INIT ───
window.addEventListener('load', () => { window.addEventListener('load', () => {
loadSavedTheme();
// Wire up audio elements from DOM // Wire up audio elements from DOM
S.audio = document.getElementById('audio-element'); S.audio = document.getElementById('audio-element');
S.previewAudio = document.getElementById('preview-audio'); S.previewAudio = document.getElementById('preview-audio');
@@ -241,7 +245,7 @@ Object.assign(window, {
openEditPlModal, closeEditPlModal, saveEditPlaylist, openEditPlModal, closeEditPlModal, saveEditPlaylist,
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, toggleNotifPanel, deleteNotif, clearNotifs, _notifNavigate, _pickTheme,
// App // App
doConnect, disconnect, doConnect, disconnect,
}); });
+70
View File
@@ -0,0 +1,70 @@
const THEME_KEY = 'ns_theme';
const DEFAULT_ID = 'dark-amber';
export const THEMES = [
{
id: 'dark-amber',
name: 'Dark Amber',
accent: '#e8a020',
bg: '#07070f',
vars: {
'--bg': '#07070f',
'--bg2': '#0d0d1a',
'--bg3': '#12121f',
'--surface': '#1a1a2e',
'--surface2': '#22223a',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.12)',
'--accent': '#e8a020',
'--accent2': '#f0b840',
'--accent-dark':'#c07010',
'--accent-dim': 'rgba(232,160,32,0.15)',
'--accent-glow':'rgba(232,160,32,0.3)',
'--text': '#f0eefc',
'--text2': '#8888aa',
'--text3': '#555577',
'--danger': '#e05555',
},
},
{
id: 'dark-ocean',
name: 'Dark Ocean',
accent: '#4f8ef7',
bg: '#070d14',
vars: {
'--bg': '#070d14',
'--bg2': '#0c1520',
'--bg3': '#111c28',
'--surface': '#182435',
'--surface2': '#1f2f42',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
'--accent': '#4f8ef7',
'--accent2': '#70aaff',
'--accent-dark':'#2a6cd6',
'--accent-dim': 'rgba(79,142,247,0.15)',
'--accent-glow':'rgba(79,142,247,0.3)',
'--text': '#edf2ff',
'--text2': '#7088aa',
'--text3': '#445566',
'--danger': '#e05555',
},
},
];
export function applyTheme(id) {
const theme = THEMES.find(t => t.id === id) || THEMES.find(t => t.id === DEFAULT_ID);
const root = document.documentElement;
Object.entries(theme.vars).forEach(([k, v]) => root.style.setProperty(k, v));
localStorage.setItem(THEME_KEY, theme.id);
}
export function loadSavedTheme() {
const saved = localStorage.getItem(THEME_KEY) || DEFAULT_ID;
applyTheme(saved);
return saved;
}
export function getCurrentThemeId() {
return localStorage.getItem(THEME_KEY) || DEFAULT_ID;
}
+18
View File
@@ -1,6 +1,7 @@
import { S } from './state.js'; import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js'; import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, ndAuth } from './api.js'; import { api, coverUrl, ndAuth } from './api.js';
import { THEMES, applyTheme, getCurrentThemeId } from './themes.js';
// Circular imports — safe (only used inside function bodies) // Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon } from './player.js'; import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon } from './player.js';
import { navigate, loadPlaylists } from './views.js'; import { navigate, loadPlaylists } from './views.js';
@@ -61,9 +62,26 @@ export function openSettings() {
document.getElementById('s-pass').value = ''; document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || ''; document.getElementById('s-backend').value = S.cfg.backendUrl || '';
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open'); document.getElementById('settings-modal').classList.add('open');
checkBackend(); checkBackend();
} }
function _renderThemePicker() {
const el = document.getElementById('theme-picker');
if (!el) return;
const current = getCurrentThemeId();
el.innerHTML = THEMES.map(t => `
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
<div class="theme-swatch-dot"></div>
<span>${esc(t.name)}</span>
</div>`).join('');
}
export function _pickTheme(id) {
applyTheme(id);
_renderThemePicker();
}
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); } export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
export function saveSettings() { export function saveSettings() {
const pass = document.getElementById('s-pass').value; const pass = document.getElementById('s-pass').value;
+11 -4
View File
@@ -2,7 +2,7 @@
--bg:#07070f;--bg2:#0d0d1a;--bg3:#12121f; --bg:#07070f;--bg2:#0d0d1a;--bg3:#12121f;
--surface:#1a1a2e;--surface2:#22223a; --surface:#1a1a2e;--surface2:#22223a;
--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12); --border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
--accent:#e8a020;--accent2:#f0b840; --accent:#e8a020;--accent2:#f0b840;--accent-dark:#c07010;
--accent-dim:rgba(232,160,32,0.15);--accent-glow:rgba(232,160,32,0.3); --accent-dim:rgba(232,160,32,0.15);--accent-glow:rgba(232,160,32,0.3);
--blue:#4f8ef7;--blue-dim:rgba(79,142,247,0.15); --blue:#4f8ef7;--blue-dim:rgba(79,142,247,0.15);
--green:#4caf7d;--green-dim:rgba(76,175,125,0.15); --green:#4caf7d;--green-dim:rgba(76,175,125,0.15);
@@ -20,7 +20,7 @@ html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--t
#setup-screen{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 60% 50% at 30% 50%,rgba(232,160,32,.08) 0%,transparent 70%),radial-gradient(ellipse 40% 60% at 80% 30%,rgba(80,60,160,.1) 0%,transparent 70%)} #setup-screen{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);background-image:radial-gradient(ellipse 60% 50% at 30% 50%,rgba(232,160,32,.08) 0%,transparent 70%),radial-gradient(ellipse 40% 60% at 80% 30%,rgba(80,60,160,.1) 0%,transparent 70%)}
.setup-card{width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:20px;padding:40px 36px;box-shadow:0 40px 80px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.04)} .setup-card{width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:20px;padding:40px 36px;box-shadow:0 40px 80px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.04)}
.setup-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px} .setup-logo{display:flex;align-items:center;gap:12px;margin-bottom:28px}
.logo-icon{width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--accent),#c07010);display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 8px 24px var(--accent-glow)} .logo-icon{width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 8px 24px var(--accent-glow)}
.setup-logo h1{font-size:24px;font-weight:700;letter-spacing:-.5px} .setup-logo h1{font-size:24px;font-weight:700;letter-spacing:-.5px}
.setup-logo h1 em{color:var(--accent);font-style:normal} .setup-logo h1 em{color:var(--accent);font-style:normal}
.setup-tabs{display:flex;gap:4px;background:var(--bg2);border-radius:8px;padding:3px;margin-bottom:24px} .setup-tabs{display:flex;gap:4px;background:var(--bg2);border-radius:8px;padding:3px;margin-bottom:24px}
@@ -32,7 +32,7 @@ html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--t
.field input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)} .field input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
.field input::placeholder{color:var(--text3)} .field input::placeholder{color:var(--text3)}
.field .hint{font-size:11px;color:var(--text3);margin-top:4px;line-height:1.4} .field .hint{font-size:11px;color:var(--text3);margin-top:4px;line-height:1.4}
.btn-primary{width:100%;margin-top:20px;padding:13px;background:linear-gradient(135deg,var(--accent),#c07010);color:#000;font-family:var(--font);font-size:15px;font-weight:700;border:none;border-radius:var(--radius-sm);cursor:pointer;transition:opacity .2s,transform .15s;letter-spacing:.2px} .btn-primary{width:100%;margin-top:20px;padding:13px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000;font-family:var(--font);font-size:15px;font-weight:700;border:none;border-radius:var(--radius-sm);cursor:pointer;transition:opacity .2s,transform .15s;letter-spacing:.2px}
.btn-primary:hover{opacity:.9;transform:translateY(-1px)} .btn-primary:hover{opacity:.9;transform:translateY(-1px)}
.btn-primary:disabled{opacity:.5;cursor:default;transform:none} .btn-primary:disabled{opacity:.5;cursor:default;transform:none}
.setup-error{margin-top:12px;padding:10px 14px;background:rgba(224,85,85,.12);border:1px solid rgba(224,85,85,.3);border-radius:var(--radius-sm);color:#ff8a8a;font-size:13px;display:none} .setup-error{margin-top:12px;padding:10px 14px;background:rgba(224,85,85,.12);border:1px solid rgba(224,85,85,.3);border-radius:var(--radius-sm);color:#ff8a8a;font-size:13px;display:none}
@@ -46,7 +46,7 @@ html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--t
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--player-h)} .sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--player-h)}
.sidebar-header{padding:18px 18px 10px;display:flex;align-items:center;justify-content:space-between} .sidebar-header{padding:18px 18px 10px;display:flex;align-items:center;justify-content:space-between}
.sidebar-logo{display:flex;align-items:center;gap:9px} .sidebar-logo{display:flex;align-items:center;gap:9px}
.sidebar-logo .icon{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--accent),#c07010);display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 4px 12px var(--accent-glow)} .sidebar-logo .icon{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 4px 12px var(--accent-glow)}
.sidebar-logo span{font-size:16px;font-weight:700} .sidebar-logo span{font-size:16px;font-weight:700}
.sidebar-logo em{color:var(--accent);font-style:normal} .sidebar-logo em{color:var(--accent);font-style:normal}
.header-btns{display:flex;gap:4px} .header-btns{display:flex;gap:4px}
@@ -737,6 +737,13 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} @keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes toastOut{to{opacity:0;transform:translateY(8px)}} @keyframes toastOut{to{opacity:0;transform:translateY(8px)}}
/* ── THEME PICKER ── */
.theme-picker{display:flex;flex-wrap:wrap;gap:8px}
.theme-swatch{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:9px;border:2px solid var(--border2);background:var(--bg2);cursor:pointer;transition:border-color .15s,background .15s;font-size:13px;font-weight:500;color:var(--text2)}
.theme-swatch:hover{border-color:var(--border2);background:var(--surface)}
.theme-swatch.active{border-color:var(--accent);color:var(--text)}
.theme-swatch-dot{width:18px;height:18px;border-radius:50%;background:var(--sw-accent);flex-shrink:0;box-shadow:inset 0 0 0 2px rgba(0,0,0,.2)}
/* ── SETTINGS MODAL ── */ /* ── SETTINGS MODAL ── */
.settings-section{margin-bottom:20px} .settings-section{margin-bottom:20px}
.settings-section h3{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);margin-bottom:10px} .settings-section h3{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);margin-bottom:10px}