From e550340565f725becad643043e422ca274714ea9 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 21 Mar 2026 22:58:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.10.0=20=E2=80=94=20new=20streaming?= =?UTF-8?q?=20themes,=20favourite=20toggle=20fix,=20home=20page=20improvem?= =?UTF-8?q?ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 23 +++ CLAUDE.md | 9 +- package.json | 2 +- packages/aur/PKGBUILD | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/api/subsonic.ts | 3 +- src/components/AlbumTrackList.tsx | 1 + src/components/ArtistRow.tsx | 46 +---- src/components/ContextMenu.tsx | 41 +++- src/components/CoverLightbox.tsx | 6 +- src/components/PlayerBar.tsx | 8 +- src/components/ThemePicker.tsx | 3 + src/i18n.ts | 28 ++- src/pages/AlbumDetail.tsx | 6 +- src/pages/ArtistDetail.tsx | 2 +- src/pages/Favorites.tsx | 2 +- src/pages/Home.tsx | 80 +++++--- src/pages/NowPlaying.tsx | 8 - src/pages/RandomMix.tsx | 8 +- src/store/playerStore.ts | 6 + src/store/themeStore.ts | 2 +- src/styles/components.css | 10 +- src/styles/layout.css | 2 +- src/styles/theme.css | 321 ++++++++++++++++++++++++++++++ 25 files changed, 509 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24062cd7..fe06dbab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.10.0] - 2026-03-22 + +### Added + +#### Three New Themes (Streaming Series) +- **Spotless**: Flat dark theme inspired by modern music streaming. Pitch-black sidebar (`#000000`), dark-grey app background (`#121212`), Spotify-green accent (`#1ED760`). White play button, green hover on primary actions. +- **DZR**: Flat light theme inspired by Deezer's modern redesign. White base, light-grey sidebar (`#F5F5F7`), purple accent (`#A238FF`). Crisp typography, large rounded radii. +- **Cupertino Beats**: Apple Music-inspired dark theme. Near-black base (`#1c1c1e`), frosted-glass sidebar and player bar with heavy `backdrop-filter`, red accent (`#fa243c`). Active nav links styled with `accent-dim` background. +- All three themes added to the **Psysonic Themes — Mediaplayer** group in the theme picker. + +### Fixed + +- **Favourite/Unfavourite toggle**: Right-clicking a song, album, or artist that is already starred now shows "Remove from Favourites" and calls `unstar()` correctly. Previously always showed "Add to Favourites" regardless of starred state. + - `Track` interface gained `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites. + - `starredOverrides: Record` added to `playerStore` — updated immediately on star/unstar so the context menu and tracklist star icons reflect changes without a page reload. +- **Home page — Artist Discovery**: Replaced card grid (which loaded artist images and caused performance issues) with lightweight pill-buttons — same `artist-ext-link` style as the "Similar Artists" section on artist pages. No image loading, instant render. +- **Now Playing page**: Queue sidebar is no longer automatically hidden when entering the Now Playing page. It now behaves like all other pages and respects the user's current queue visibility setting. +- **Random Mix filter panel**: Background now correctly uses `--bg-card` instead of the undefined `--bg-elevated` token, which caused the panel to render transparent in most themes. + +### Changed + +- **Home page layout**: Section order is now: Recently Added → Discover → Artist Discovery → Starred → Most Played. + ## [1.9.0] - 2026-03-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 94cb2a14..32953687 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,9 @@ Add a function to `src/api/subsonic.ts` using the `api()` helper. The helper | `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` | | `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` | | `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` | +| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` | +| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` | +| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` | **Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm). @@ -207,7 +210,7 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components. - **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover. - **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`. -- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. +- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites. - **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour. - **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow. - **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible. @@ -222,4 +225,6 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden. - **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button. - **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). -- **Version**: 1.9.0 +- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. +- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors. +- **Version**: 1.10.0 diff --git a/package.json b/package.json index a3a72f96..33371c0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.9.0", + "version": "1.10.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index becd8538..c05e724d 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: stelle pkgname=psysonic -pkgver=1.9.0 +pkgver=1.10.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1eafeed2..e3203fb0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.9.0" +version = "1.10.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fa02cc49..8cca8e46 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.9.0", + "version": "1.10.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 8b63e573..d9c8d348 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; +import { version } from '../../package.json'; // ─── Secure random salt ──────────────────────────────────────── function secureRandomSalt(): string { @@ -13,7 +14,7 @@ function secureRandomSalt(): string { function getAuthParams(username: string, password: string) { const salt = secureRandomSalt(); const token = md5(password + salt); - return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' }; + return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' }; } function getClient() { diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 3f3663f4..2048717f 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -86,6 +86,7 @@ export default function AlbumTrackList({ albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, + starred: song.starred, }); return ( diff --git a/src/components/ArtistRow.tsx b/src/components/ArtistRow.tsx index 568c7a14..661cf912 100644 --- a/src/components/ArtistRow.tsx +++ b/src/components/ArtistRow.tsx @@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic'; import ArtistCardLocal from './ArtistCardLocal'; import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; interface Props { title: string; artists: SubsonicArtist[]; moreLink?: string; moreText?: string; - onLoadMore?: () => Promise; } -export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) { - const { t } = useTranslation(); +export default function ArtistRow({ title, artists, moreLink, moreText }: Props) { const scrollRef = useRef(null); const navigate = useNavigate(); const [showLeft, setShowLeft] = useState(false); const [showRight, setShowRight] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - - const loadingRef = useRef(false); const handleScroll = () => { if (!scrollRef.current) return; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; setShowLeft(scrollLeft > 0); setShowRight(scrollLeft < scrollWidth - clientWidth - 5); - - // Auto-load trigger - if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) { - triggerLoadMore(); - } - }; - - const triggerLoadMore = async () => { - if (!onLoadMore || loadingRef.current) return; - loadingRef.current = true; - setLoadingMore(true); - await onLoadMore(); - setLoadingMore(false); - loadingRef.current = false; }; useEffect(() => { @@ -63,35 +43,19 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo

{title}

- -
- +
{artists.map(a => )} - {loadingMore && ( -
-
-
-
- {t('common.loadingMore')} -
- )} - {!loadingMore && moreLink && ( + {moreLink && (
navigate(moreLink)}>
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 201d3fce..8ba902f4 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string { export default function ContextMenu() { const { t } = useTranslation(); - const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore(); + const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore(); const auth = useAuthStore(); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); @@ -53,6 +53,9 @@ export default function ContextMenu() { const { type, item, queueIndex } = contextMenu; + const isStarred = (id: string, itemStarred?: string) => + id in starredOverrides ? starredOverrides[id] : !!itemStarred; + const handleAction = async (action: () => void | Promise) => { closeContextMenu(); await action(); @@ -148,8 +151,13 @@ export default function ContextMenu() {
handleAction(() => startRadio(song.artist, song.artist))}> {t('contextMenu.startRadio')}
-
handleAction(() => star(song.id, 'song'))}> - {t('contextMenu.favorite')} +
handleAction(() => { + const starred = isStarred(song.id, song.starred); + setStarredOverride(song.id, !starred); + return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + })}> + + {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
{auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; @@ -181,8 +189,13 @@ export default function ContextMenu() {
handleAction(() => navigate(`/artist/${album.artistId}`))}> {t('contextMenu.goToArtist')}
-
handleAction(() => star(album.id, 'album'))}> - {t('contextMenu.favoriteAlbum')} +
handleAction(() => { + const starred = isStarred(album.id, album.starred); + setStarredOverride(album.id, !starred); + return starred ? unstar(album.id, 'album') : star(album.id, 'album'); + })}> + + {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
handleAction(() => downloadAlbum(album.name, album.id))}> {t('contextMenu.download')} @@ -199,8 +212,13 @@ export default function ContextMenu() { {t('contextMenu.startRadio')}
-
handleAction(() => star(artist.id, 'artist'))}> - {t('contextMenu.favoriteArtist')} +
handleAction(() => { + const starred = isStarred(artist.id, artist.starred); + setStarredOverride(artist.id, !starred); + return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist'); + })}> + + {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
); @@ -224,8 +242,13 @@ export default function ContextMenu() { {t('contextMenu.openAlbum')}
)} -
handleAction(() => star(song.id, 'song'))}> - {t('contextMenu.favorite')} +
handleAction(() => { + const starred = isStarred(song.id, song.starred); + setStarredOverride(song.id, !starred); + return starred ? unstar(song.id, 'song') : star(song.id, 'song'); + })}> + + {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
{auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; diff --git a/src/components/CoverLightbox.tsx b/src/components/CoverLightbox.tsx index 5a01eec0..3c362294 100644 --- a/src/components/CoverLightbox.tsx +++ b/src/components/CoverLightbox.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; interface Props { @@ -14,7 +15,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) { return () => window.removeEventListener('keydown', onKey); }, [onClose]); - return ( + return createPortal(
{alt} e.stopPropagation()} /> -
+
, + document.body ); } diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 7d09b864..a6abaa5a 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,4 +1,5 @@ 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 @@ -175,8 +176,8 @@ export default function PlayerBar() { />
- {/* EQ Popup */} - {eqOpen && ( + {/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} + {eqOpen && createPortal( <>
setEqOpen(false)} />
@@ -188,7 +189,8 @@ export default function PlayerBar() {
- + , + document.body )} diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index c2310a85..1c02f748 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -26,6 +26,9 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [ { 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' }, ], }, { diff --git a/src/i18n.ts b/src/i18n.ts index 4117ab3b..2c1da0bd 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -30,7 +30,9 @@ const enTranslation = { mostPlayed: 'Most Played', discover: 'Discover', loadMore: 'Load More', - discoverMore: 'Discover More' + discoverMore: 'Discover More', + discoverArtists: 'Discover Artists', + discoverArtistsMore: 'All Artists' }, hero: { eyebrow: 'Featured Album', @@ -74,6 +76,9 @@ const enTranslation = { favorite: 'Favorite', favoriteArtist: 'Favorite Artist', favoriteAlbum: 'Favorite Album', + unfavorite: 'Remove from Favorites', + unfavoriteArtist: 'Remove Artist from Favorites', + unfavoriteAlbum: 'Remove Album from Favorites', removeFromQueue: 'Remove from Queue', openAlbum: 'Open Album', goToArtist: 'Go to Artist', @@ -551,7 +556,9 @@ const deTranslation = { mostPlayed: 'Meistgehört', discover: 'Entdecken', loadMore: 'Mehr laden', - discoverMore: 'Mehr entdecken' + discoverMore: 'Mehr entdecken', + discoverArtists: 'Künstler entdecken', + discoverArtistsMore: 'Alle Künstler' }, hero: { eyebrow: 'Album des Augenblicks', @@ -595,6 +602,9 @@ const deTranslation = { favorite: 'Favorisieren', favoriteArtist: 'Künstler favorisieren', favoriteAlbum: 'Album favorisieren', + unfavorite: 'Aus Favoriten entfernen', + unfavoriteArtist: 'Künstler aus Favoriten entfernen', + unfavoriteAlbum: 'Album aus Favoriten entfernen', removeFromQueue: 'Diesen Song entfernen', openAlbum: 'Album öffnen', goToArtist: 'Zum Künstler', @@ -1072,7 +1082,9 @@ const frTranslation = { mostPlayed: 'Les plus écoutés', discover: 'Découvrir', loadMore: 'Charger plus', - discoverMore: 'Découvrir plus' + discoverMore: 'Découvrir plus', + discoverArtists: 'Découvrir des artistes', + discoverArtistsMore: 'Tous les artistes' }, hero: { eyebrow: 'Album en vedette', @@ -1116,6 +1128,9 @@ const frTranslation = { favorite: 'Favori', favoriteArtist: 'Artiste favori', favoriteAlbum: 'Album favori', + unfavorite: 'Retirer des favoris', + unfavoriteArtist: 'Retirer l\'artiste des favoris', + unfavoriteAlbum: 'Retirer l\'album des favoris', removeFromQueue: 'Retirer de la file', openAlbum: 'Ouvrir l\'album', goToArtist: 'Aller à l\'artiste', @@ -1593,7 +1608,9 @@ const nlTranslation = { mostPlayed: 'Meest gespeeld', discover: 'Ontdekken', loadMore: 'Meer laden', - discoverMore: 'Meer ontdekken' + discoverMore: 'Meer ontdekken', + discoverArtists: 'Artiesten ontdekken', + discoverArtistsMore: 'Alle artiesten' }, hero: { eyebrow: 'Uitgelicht album', @@ -1637,6 +1654,9 @@ const nlTranslation = { favorite: 'Favoriet', favoriteArtist: 'Favoriete artiest', favoriteAlbum: 'Favoriet album', + unfavorite: 'Verwijderen uit favorieten', + unfavoriteArtist: 'Artiest uit favorieten verwijderen', + unfavoriteAlbum: 'Album uit favorieten verwijderen', removeFromQueue: 'Uit wachtrij verwijderen', openAlbum: 'Album openen', goToArtist: 'Naar artiest', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index c31d1ce9..2e467c63 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -29,6 +29,7 @@ export default function AlbumDetail() { const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); + const starredOverrides = usePlayerStore(s => s.starredOverrides); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); @@ -218,7 +219,10 @@ export default function AlbumDetail() { hoveredSongId={hoveredSongId} setHoveredSongId={setHoveredSongId} ratings={ratings} - starredSongs={starredSongs} + starredSongs={new Set([ + ...[...starredSongs].filter(id => starredOverrides[id] !== false), + ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), + ])} onPlaySong={handlePlaySong} onRate={handleRate} onToggleSongStar={toggleSongStar} diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 717a1333..278e8de1 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -341,7 +341,7 @@ export default function ArtistDetail() { const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, - year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, + year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, }; openContextMenu(e.clientX, e.clientY, track, 'song'); }} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 3f7b91e4..96fb286f 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -87,7 +87,7 @@ export default function Favorites() { const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, - track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, + track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, }; return (
([]); @@ -10,20 +11,29 @@ export default function Home() { const [random, setRandom] = useState([]); const [heroAlbums, setHeroAlbums] = useState([]); const [mostPlayed, setMostPlayed] = useState([]); + const [randomArtists, setRandomArtists] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { Promise.all([ getAlbumList('starred', 12).catch(() => []), getAlbumList('newest', 12).catch(() => []), - getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover + getAlbumList('random', 20).catch(() => []), getAlbumList('frequent', 12).catch(() => []), - ]).then(([s, n, r, f]) => { + getArtists().catch(() => []), + ]).then(([s, n, r, f, artists]) => { setStarred(s); setRecent(n); setHeroAlbums(r.slice(0, 8)); setRandom(r.slice(8)); setMostPlayed(f); + // Pick 16 random artists via Fisher-Yates shuffle + const shuffled = [...artists]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + setRandomArtists(shuffled.slice(0, 16)); setLoading(false); }).catch(() => setLoading(false)); }, []); @@ -35,17 +45,15 @@ export default function Home() { ) => { try { const more = await getAlbumList(type, 12, currentList.length); - // Ensure we don't append duplicates if the API returns them const newItems = more.filter(m => !currentList.find(c => c.id === m.id)); - if (newItems.length > 0) { - setter(prev => [...prev, ...newItems]); - } + if (newItems.length > 0) setter(prev => [...prev, ...newItems]); } catch (e) { console.error('Failed to load more', e); } }; const { t } = useTranslation(); + const navigate = useNavigate(); return (
@@ -58,31 +66,49 @@ export default function Home() {
) : ( <> + loadMore('newest', recent, setRecent)} + moreText={t('home.loadMore')} + /> + loadMore('random', random, setRandom)} + moreText={t('home.discoverMore')} + /> + {randomArtists.length > 0 && ( +
+
+

{t('home.discoverArtists')}

+
+
+ {randomArtists.map(a => ( + + ))} + +
+
+ )} {starred.length > 0 && ( - loadMore('starred', starred, setStarred)} - moreText={t('home.loadMore')} + moreText={t('home.loadMore')} /> )} - loadMore('newest', recent, setRecent)} - moreText={t('home.loadMore')} - /> - loadMore('frequent', mostPlayed, setMostPlayed)} - moreText={t('home.loadMore')} - /> - loadMore('random', random, setRandom)} - moreText={t('home.discoverMore')} + moreText={t('home.loadMore')} /> )} diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index d425c779..cf42157c 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -173,14 +173,6 @@ export default function NowPlaying() { const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); - const setQueueVisible = usePlayerStore(s => s.setQueueVisible); - - // Hide queue panel while on this page, restore on leave - useEffect(() => { - const wasVisible = usePlayerStore.getState().isQueueVisible; - if (wasVisible) setQueueVisible(false); - return () => { if (wasVisible) setQueueVisible(true); }; - }, [setQueueVisible]); // Extra song metadata const [songMeta, setSongMeta] = useState(null); diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 6034ba3d..d4f1941f 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -210,7 +210,7 @@ export default function RandomMix() { overflow: 'hidden', }}> {/* Left: Blacklist */} -
+
{t('randomMix.filterPanelTitle')}
@@ -291,7 +291,7 @@ export default function RandomMix() {
{/* Right: Genre Mix */} -
+
{t('randomMix.genreMixTitle')}
@@ -342,7 +342,7 @@ export default function RandomMix() { {genreMixSongs.map(song => (
playTrack(song, genreMixSongs)} role="row" draggable - onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }} + onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }, 'song'); }} onDragStart={e => { e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } })); @@ -391,7 +391,7 @@ export default function RandomMix() { draggable onContextMenu={e => { e.preventDefault(); - const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }; + const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred }; setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 156af7c4..232e4c8c 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -23,6 +23,7 @@ export interface Track { replayGainTrackDb?: number; replayGainAlbumDb?: number; replayGainPeak?: number; + starred?: string; } export function songToTrack(song: SubsonicSong): Track { @@ -43,6 +44,7 @@ export function songToTrack(song: SubsonicSong): Track { replayGainTrackDb: song.replayGain?.trackGain, replayGainAlbumDb: song.replayGain?.albumGain, replayGainPeak: song.replayGain?.trackPeak, + starred: song.starred, }; } @@ -58,6 +60,8 @@ interface PlayerState { scrobbled: boolean; lastfmLoved: boolean; lastfmLovedCache: Record; + starredOverrides: Record; + setStarredOverride: (id: string, starred: boolean) => void; playTrack: (track: Track, queue?: Track[]) => void; pause: () => void; @@ -277,6 +281,8 @@ export const usePlayerStore = create()( scrobbled: false, lastfmLoved: false, lastfmLovedCache: {}, + starredOverrides: {}, + setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })), isQueueVisible: true, isFullscreenOpen: false, repeatMode: 'off', diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index fa32860b..48a06217 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light'; +type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light' | 'spotless' | 'dzr0' | 'cupertino-beats'; interface ThemeState { theme: Theme; diff --git a/src/styles/components.css b/src/styles/components.css index 38a7df47..7acee780 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -435,6 +435,7 @@ background: var(--accent); } + .album-card-info { padding: var(--space-3) var(--space-4) var(--space-4); } @@ -664,16 +665,17 @@ z-index: 9000; background: rgba(0, 0, 0, 0.88); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding-top: 8vh; + padding: 40px; + box-sizing: border-box; cursor: zoom-out; backdrop-filter: blur(6px); } .cover-lightbox-img { - max-width: 90vw; - max-height: 90vh; + max-width: 100%; + max-height: 100%; object-fit: contain; border-radius: var(--radius-lg); box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8); diff --git a/src/styles/layout.css b/src/styles/layout.css index 857b4961..be58b177 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -576,7 +576,7 @@ box-sizing: border-box; padding: 0 var(--space-4); background: var(--bg-app); - border-bottom: 1px solid var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); display: flex; justify-content: space-between; align-items: center; diff --git a/src/styles/theme.css b/src/styles/theme.css index 07f2f326..c73eefd5 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1249,6 +1249,327 @@ --player-height: 88px; } +/* ─── Spotless ─── */ +[data-theme='spotless'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23B3B3B3%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #000000; + --ctp-mantle: #121212; + --ctp-base: #181818; + --ctp-surface0: #282828; + --ctp-surface1: #3E3E3E; + --ctp-surface2: #535353; + --ctp-overlay0: #727272; + --ctp-overlay1: #A7A7A7; + --ctp-overlay2: #B3B3B3; + --ctp-text: #FFFFFF; + --ctp-subtext1: #EBEBEB; + --ctp-subtext0: #D6D6D6; + + --ctp-mauve: #1ED760; + --ctp-lavender: #1FDF64; + --ctp-green: #1ED760; + --ctp-teal: #1DF2A4; + --ctp-sky: #3D91F4; + --ctp-blue: #2E77D0; + --ctp-sapphire: #1C5599; + --ctp-pink: #E55C5C; + --ctp-flamingo: #E55C5C; + --ctp-rosewater:#FFC8C8; + --ctp-yellow: #FFA42B; + --ctp-peach: #FF7A00; + --ctp-maroon: #B22A2A; + --ctp-red: #E22134; + + --bg-app: #121212; + --bg-sidebar: #000000; + --bg-card: #181818; + --bg-hover: #282828; + --bg-player: #181818; + --bg-glass: rgba(18, 18, 18, 0.85); + + --accent: #1ED760; + --accent-dim: rgba(30, 215, 96, 0.15); + --accent-glow: rgba(30, 215, 96, 0.35); + --volume-accent: #FFFFFF; + + --text-primary: #FFFFFF; + --text-secondary:#B3B3B3; + --text-muted: #A7A7A7; + + --border: #282828; + --border-subtle: rgba(255, 255, 255, 0.1); + + --positive: #1ED760; + --warning: #FFA42B; + --danger: #E22134; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.5); + --shadow-md: 0 4px 16px rgba(0,0,0,0.6); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.7); + + --transition-fast: 150ms ease; + --transition-med: 250ms ease; + --transition-slow: 350ms ease; + + --sidebar-width: 220px; + --player-height: 88px; +} + +[data-theme='spotless'] .sidebar { + border-right: none; +} + +[data-theme='spotless'] .player-bar { + border-top: 1px solid var(--border-subtle); +} + +[data-theme='spotless'] .nav-link:hover { + color: var(--text-primary); + background-color: transparent; +} + +[data-theme='spotless'] .nav-link.active { + background-color: var(--bg-hover); + color: var(--text-primary); + border-radius: var(--radius-sm); +} + +[data-theme='spotless'] .nav-link.active::before { + display: none; +} + +[data-theme='spotless'] .btn-primary { + color: var(--ctp-crust); + border-radius: var(--radius-full); + font-weight: 700; +} + +[data-theme='spotless'] .player-btn-primary { + color: var(--ctp-crust); + background-color: var(--text-primary); + border-radius: var(--radius-full); +} + +[data-theme='spotless'] .player-btn-primary:hover { + background-color: var(--accent); + transform: scale(1.05); +} + +[data-theme='spotless'] .queue-item:hover, +[data-theme='spotless'] .track-row:hover { + background-color: var(--bg-hover); + border-radius: var(--radius-sm); +} + +/* ─── Cupertino Beats ─── */ +[data-theme='cupertino-beats'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a1a1a1%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #000000; + --ctp-mantle: #121212; + --ctp-base: #1c1c1e; + --ctp-surface0: #2c2c2e; + --ctp-surface1: #3a3a3c; + --ctp-surface2: #48484a; + --ctp-overlay0: #636366; + --ctp-overlay1: #8e8e93; + --ctp-overlay2: #aeaeb2; + --ctp-text: #ffffff; + --ctp-subtext1: #ebebf599; + --ctp-subtext0: #ebebf560; + + --ctp-mauve: #fa243c; + --ctp-lavender: #ff5e71; + --ctp-green: #34c759; + --ctp-teal: #30b0c7; + --ctp-sky: #5ac8fa; + --ctp-blue: #007aff; + --ctp-sapphire: #0051d5; + --ctp-pink: #ff2d55; + --ctp-flamingo: #ff3b30; + --ctp-rosewater:#ff9f0a; + --ctp-yellow: #ffcc00; + --ctp-peach: #ff9500; + --ctp-maroon: #af52de; + --ctp-red: #ff3b30; + + --bg-app: #1c1c1e; + --bg-sidebar: rgba(18, 18, 18, 0.75); + --bg-card: #2c2c2e; + --bg-hover: rgba(255, 255, 255, 0.08); + --bg-player: rgba(0, 0, 0, 0.85); + --bg-glass: rgba(28, 28, 30, 0.6); + + --accent: #fa243c; + --accent-dim: rgba(250, 36, 60, 0.15); + --accent-glow: rgba(250, 36, 60, 0.3); + --volume-accent: #fa243c; + + --text-primary: #ffffff; + --text-secondary:rgba(235, 235, 245, 0.6); + --text-muted: rgba(235, 235, 245, 0.3); + + --border: rgba(255, 255, 255, 0.1); + --border-subtle: rgba(255, 255, 255, 0.05); + + --positive: #34c759; + --warning: #ffcc00; + --danger: #ff3b30; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.5); + --shadow-md: 0 4px 20px rgba(0,0,0,0.4); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.6); + + --transition-fast: 150ms ease; + --transition-med: 250ms ease; + --transition-slow: 350ms ease; + + --sidebar-width: 220px; + --player-height: 88px; +} + +[data-theme='cupertino-beats'] .sidebar { + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + border-right: 1px solid var(--border); +} + +[data-theme='cupertino-beats'] .player-bar { + backdrop-filter: blur(30px) saturate(150%); + -webkit-backdrop-filter: blur(30px) saturate(150%); + border-top: 1px solid var(--border); +} + +[data-theme='cupertino-beats'] .nav-link.active { + background: var(--accent-dim); + color: var(--accent); + font-weight: 600; +} + +[data-theme='cupertino-beats'] .nav-link.active::before { + width: 4px; + border-radius: 0 4px 4px 0; +} + +/* ─── Dzr0 ─── */ +[data-theme='dzr0'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23A238FF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #E5E5E9; + --ctp-mantle: #F5F5F7; + --ctp-base: #FFFFFF; + --ctp-surface0: #F0F0F3; + --ctp-surface1: #E8E8EC; + --ctp-surface2: #D8D8DF; + --ctp-overlay0: #9E9EAF; + --ctp-overlay1: #7C7C8F; + --ctp-overlay2: #5D5D70; + --ctp-text: #121216; + --ctp-subtext1: #4A4A5A; + --ctp-subtext0: #6A6A7A; + + --ctp-mauve: #A238FF; + --ctp-lavender: #B86BFF; + --ctp-green: #00C171; + --ctp-teal: #00C171; + --ctp-sky: #3D91F4; + --ctp-blue: #2E77D0; + --ctp-sapphire: #1C5599; + --ctp-pink: #EF3B5D; + --ctp-flamingo: #EF3B5D; + --ctp-rosewater:#FFC8C8; + --ctp-yellow: #FFCC00; + --ctp-peach: #FF7A00; + --ctp-maroon: #B22A2A; + --ctp-red: #EF3B5D; + + --bg-app: #FFFFFF; + --bg-sidebar: #F5F5F7; + --bg-card: #FBFBFC; + --bg-hover: #F0F0F3; + --bg-player: #FFFFFF; + --bg-glass: rgba(255, 255, 255, 0.85); + + --accent: #A238FF; + --accent-dim: rgba(162, 56, 255, 0.12); + --accent-glow: rgba(162, 56, 255, 0.25); + --volume-accent: #A238FF; + + --text-primary: #121216; + --text-secondary:#4A4A5A; + --text-muted: #8E8E9F; + + --border: #E2E2E9; + --border-subtle: #F0F0F5; + + --positive: #00C171; + --warning: #FFCC00; + --danger: #EF3B5D; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); + --shadow-md: 0 4px 16px rgba(0,0,0,0.10); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.12); + + --transition-fast: 150ms ease; + --transition-med: 250ms ease; + --transition-slow: 350ms ease; + + --sidebar-width: 220px; + --player-height: 88px; +} + +[data-theme='dzr0'] .player-bar { + border-top: 1px solid var(--border-subtle); + box-shadow: 0 -4px 12px rgba(0,0,0,0.03); +} + +[data-theme='dzr0'] .player-track-name, +[data-theme='dzr0'] .player-time, +[data-theme='dzr0'] .player-track-artist { + color: var(--text-primary) !important; +} + +[data-theme='dzr0'] .sidebar { + border-right: 1px solid var(--border-subtle); +} + +[data-theme='dzr0'] .nav-link:hover { + background-color: var(--bg-hover); + color: var(--accent); +} + +[data-theme='dzr0'] .nav-link.active { + background-color: var(--accent-dim); + color: var(--accent); + font-weight: 600; +} + +[data-theme='dzr0'] .btn-primary { + color: #FFFFFF !important; +} + /* ─── Reset ─── */ *, *::before, *::after { box-sizing: border-box;