feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-22 13:10:06 +01:00
parent 867c5fbd3e
commit d927ef2082
24 changed files with 2168 additions and 411 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ export default function AlbumHeader({
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+1 -1
View File
@@ -156,7 +156,7 @@ export default function AlbumTrackList({
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
+15 -1
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -241,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={14} />
</button>
</div>
</div>
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
interface Props {
currentTrack: Track | null;
}
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1);
useEffect(() => {
if (!currentTrack || currentTrack.id === fetchedFor) return;
let cancelled = false;
setSyncedLines(null);
setPlainLyrics(null);
setNotFound(false);
setLoading(true);
lineRefs.current = [];
prevActive.current = -1;
fetchLyrics(
currentTrack.artist ?? '',
currentTrack.title,
currentTrack.album ?? '',
currentTrack.duration ?? 0,
).then(result => {
if (cancelled) return;
setLoading(false);
setFetchedFor(currentTrack.id);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
setNotFound(true);
return;
}
if (result.syncedLyrics) {
const lines = parseLrc(result.syncedLyrics);
setSyncedLines(lines.length > 0 ? lines : null);
}
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) { setLoading(false); setNotFound(true); }
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset when track changes
useEffect(() => {
setFetchedFor(null);
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
useEffect(() => {
if (activeIdx < 0 || activeIdx === prevActive.current) return;
prevActive.current = activeIdx;
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeIdx]);
if (!currentTrack) {
return (
<div className="lyrics-pane-empty">
<p className="lyrics-status">{t('player.lyricsNotFound')}</p>
</div>
);
}
return (
<div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
{hasSynced && (
<div className="lyrics-synced">
{syncedLines!.map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
>
{line.text || '\u00A0'}
</div>
))}
</div>
)}
{!hasSynced && plainLyrics && (
<div className="lyrics-plain">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
))}
</div>
)}
</div>
);
}
+16 -2
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -12,6 +12,7 @@ import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -24,11 +25,14 @@ export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
@@ -115,7 +119,7 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
@@ -140,6 +144,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* Lyrics Button */}
<button
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={15} />
</button>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
+30 -2
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -139,6 +141,9 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
@@ -348,7 +353,8 @@ export default function QueuePanel() {
</div>
)}
<div className="queue-toolbar">
{activeTab === 'queue' ? (<>
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
@@ -472,6 +478,28 @@ export default function QueuePanel() {
})
)}
</div>
</>) : (
<LyricsPane currentTrack={currentTrack} />
)}
<div className="queue-tab-bar">
<button
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
onClick={() => setTab('queue')}
aria-label={t('queue.title')}
>
<ListMusic size={14} />
{t('queue.title')}
</button>
<button
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
onClick={() => setTab('lyrics')}
aria-label={t('player.lyrics')}
>
<MicVocal size={14} />
{t('player.lyrics')}
</button>
</div>
{saveModalOpen && (
<SavePlaylistModal
+169 -155
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react';
import { useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef {
id: string;
@@ -10,81 +11,85 @@ interface ThemeDef {
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Psysonic Themes',
group: 'Games',
themes: [
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
],
},
{
group: 'Operating Systems',
themes: [
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'aero-glass', label: 'Aero Glass', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'luna-teal', label: 'Luna Teal', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Catppuccin',
themes: [
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
],
},
{
group: 'Nord',
themes: [
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
],
},
{
group: 'Retro',
themes: [
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
],
},
{
group: 'Tokyo Night',
themes: [
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
],
},
{
group: 'Movies',
themes: [
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
],
},
{
group: 'Open Source Classics',
themes: [
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
],
},
{
group: 'Operating Systems',
themes: [
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Psysonic Themes',
themes: [
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
],
},
{
group: 'Series',
themes: [
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
],
},
];
@@ -95,93 +100,102 @@ interface Props {
}
export default function ThemePicker({ value, onChange }: Props) {
const initialOpen = THEME_GROUPS.find(g => g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group;
const [openGroup, setOpenGroup] = useState<string | null>(initialOpen);
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{THEME_GROUPS.map(({ group, themes }) => (
<div key={group}>
<div style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-muted)',
marginBottom: '10px',
}}>
{group}
<div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => {
const isOpen = openGroup === group;
const hasActive = themes.some(t => t.id === value);
return (
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
<button className="theme-accordion-header" onClick={() => toggle(group)}>
<span>
{group}
{hasActive && !isOpen && (
<span className="theme-accordion-active-dot" />
)}
</span>
<ChevronDown size={15} className="theme-accordion-chevron" />
</button>
{isOpen && (
<div className="theme-accordion-content">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
{/* main bg */}
<div style={{ background: t.bg, height: '55%' }} />
{/* card tone */}
<div style={{ background: t.card, height: '20%' }} />
{/* accent bar */}
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
))}
);
})}
</div>
);
}