mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
Merge pull request #186 from kveld9/redesign/albums-playlists-headers
redesign/albums playlists headers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
@@ -86,6 +86,7 @@ interface AlbumHeaderProps {
|
||||
onRemoveOffline: () => void;
|
||||
onPlayAll: () => void;
|
||||
onEnqueueAll: () => void;
|
||||
onShuffleAll?: () => void;
|
||||
onBio: () => void;
|
||||
onCloseBio: () => void;
|
||||
entityRatingValue: number;
|
||||
@@ -112,6 +113,7 @@ export default function AlbumHeader({
|
||||
onRemoveOffline,
|
||||
onPlayAll,
|
||||
onEnqueueAll,
|
||||
onShuffleAll,
|
||||
onBio,
|
||||
onCloseBio,
|
||||
entityRatingValue,
|
||||
@@ -121,12 +123,12 @@ export default function AlbumHeader({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,7 +171,6 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
@@ -295,27 +296,33 @@ export default function AlbumHeader({
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={onShuffleAll}
|
||||
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
className="btn btn-ghost"
|
||||
onClick={onEnqueueAll}
|
||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||
>
|
||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
id="album-star-btn"
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
{t('albumDetail.favorite')}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
@@ -136,6 +137,22 @@ const handleEnqueueAll = () => {
|
||||
enqueue(tracks);
|
||||
};
|
||||
|
||||
const handleShuffleAll = () => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
const tracks = album.songs.map(s => {
|
||||
const t = songToTrack(s);
|
||||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||||
return t;
|
||||
});
|
||||
const shuffled = [...tracks];
|
||||
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]];
|
||||
}
|
||||
if (shuffled[0]) playTrack(shuffled[0], shuffled);
|
||||
};
|
||||
|
||||
const handlePlaySong = (song: SubsonicSong) => {
|
||||
if (!album) return;
|
||||
const albumGenre = album.album.genre;
|
||||
@@ -352,6 +369,7 @@ const handleEnqueueAll = () => {
|
||||
onDownload={handleDownload}
|
||||
onPlayAll={handlePlayAll}
|
||||
onEnqueueAll={handleEnqueueAll}
|
||||
onShuffleAll={handleShuffleAll}
|
||||
onBio={handleBio}
|
||||
onCloseBio={() => setBioOpen(false)}
|
||||
offlineStatus={resolvedOfflineStatus}
|
||||
@@ -378,8 +396,9 @@ const handleEnqueueAll = () => {
|
||||
{songs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
className="input"
|
||||
className="input-search"
|
||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||
placeholder={t('albumDetail.filterSongs')}
|
||||
value={filterText}
|
||||
@@ -387,9 +406,12 @@ const handleEnqueueAll = () => {
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setFilterText('')}
|
||||
>×</button>
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+143
-52
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react';
|
||||
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||
@@ -41,6 +42,11 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
return formatHumanHoursMinutes(total);
|
||||
@@ -107,7 +113,6 @@ export default function PlaylistDetail() {
|
||||
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
@@ -224,6 +229,11 @@ export default function PlaylistDetail() {
|
||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
|
||||
// ── Column picker portal dropdown state ────────────────────────────────────
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number } | null>(null);
|
||||
const pickerBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Column resize/visibility ──────────────────────────────────────────────
|
||||
const {
|
||||
colVisible, visibleCols, gridStyle,
|
||||
@@ -238,6 +248,41 @@ export default function PlaylistDetail() {
|
||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
// Click-outside handler for column picker portal dropdown
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
pickerBtnRef.current?.contains(target) ||
|
||||
pickerRef.current?.contains(target) ||
|
||||
pickerMenuRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [pickerOpen, setPickerOpen]);
|
||||
|
||||
// Update picker position on resize/scroll while open
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const updatePos = () => {
|
||||
if (pickerBtnRef.current) {
|
||||
const rect = pickerBtnRef.current.getBoundingClientRect();
|
||||
setPickerPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', updatePos);
|
||||
window.addEventListener('scroll', updatePos, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePos);
|
||||
window.removeEventListener('scroll', updatePos, true);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
|
||||
@@ -527,6 +572,30 @@ export default function PlaylistDetail() {
|
||||
setDropTargetIdx({ idx, before });
|
||||
};
|
||||
|
||||
// ── Playback actions (encapsulated like AlbumHeader) ─────────
|
||||
const handlePlayAll = useCallback(() => {
|
||||
if (!songs.length || !id) return;
|
||||
touchPlaylist(id);
|
||||
playTrack(tracks[0], tracks);
|
||||
}, [songs.length, id, tracks, touchPlaylist, playTrack]);
|
||||
|
||||
const handleShuffleAll = useCallback(() => {
|
||||
if (!songs.length || !id) return;
|
||||
touchPlaylist(id);
|
||||
const shuffled = [...tracks];
|
||||
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]];
|
||||
}
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}, [songs.length, id, tracks, touchPlaylist, playTrack]);
|
||||
|
||||
const handleEnqueueAll = useCallback(() => {
|
||||
if (!songs.length || !id) return;
|
||||
touchPlaylist(id);
|
||||
enqueue(tracks);
|
||||
}, [songs.length, id, tracks, touchPlaylist, enqueue]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -541,7 +610,7 @@ export default function PlaylistDetail() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="album-detail animate-fade-in">
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<div className="album-detail-header">
|
||||
@@ -588,10 +657,9 @@ export default function PlaylistDetail() {
|
||||
)}
|
||||
|
||||
<div className="album-detail-meta">
|
||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
|
||||
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6 }}>{playlist.name}</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
@@ -619,31 +687,24 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
playTrack(tracks[0], tracks);
|
||||
}}>
|
||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={handlePlayAll}>
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
const shuffled = [...tracks];
|
||||
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]];
|
||||
}
|
||||
playTrack(shuffled[0], shuffled);
|
||||
}}>
|
||||
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={songs.length === 0}
|
||||
onClick={handleShuffleAll}
|
||||
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
||||
if (!songs.length) return;
|
||||
touchPlaylist(id!);
|
||||
enqueue(tracks);
|
||||
}}>
|
||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={songs.length === 0}
|
||||
onClick={handleEnqueueAll}
|
||||
data-tooltip={t('playlists.addToQueue')}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -653,6 +714,21 @@ export default function PlaylistDetail() {
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
{/* search close resets selection */}
|
||||
{songs.length > 0 && (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
|
||||
<Download size={16} /> {t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{songs.length > 0 && id && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
@@ -668,26 +744,24 @@ export default function PlaylistDetail() {
|
||||
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
||||
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
|
||||
</>
|
||||
) : isCached ? (
|
||||
<>
|
||||
<Trash2 size={16} />
|
||||
{t('playlists.removeOffline')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('playlists.cacheOffline')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{songs.length > 0 && (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
|
||||
<Download size={16} /> {t('playlists.downloadZip')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -793,8 +867,9 @@ export default function PlaylistDetail() {
|
||||
{songs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
||||
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
className="input"
|
||||
className="input-search"
|
||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||
placeholder={t('albumDetail.filterSongs')}
|
||||
value={filterText}
|
||||
@@ -802,9 +877,12 @@ export default function PlaylistDetail() {
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
|
||||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setFilterText('')}
|
||||
>×</button>
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -966,14 +1044,26 @@ export default function PlaylistDetail() {
|
||||
</div>
|
||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||
<button
|
||||
ref={pickerBtnRef}
|
||||
className="tracklist-col-picker-btn"
|
||||
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (!pickerOpen && pickerBtnRef.current) {
|
||||
const rect = pickerBtnRef.current.getBoundingClientRect();
|
||||
setPickerPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||
}
|
||||
setPickerOpen(v => !v);
|
||||
}}
|
||||
data-tooltip={t('albumDetail.columns')}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{pickerOpen && (
|
||||
<div className="tracklist-col-picker-menu">
|
||||
{pickerOpen && pickerPos && createPortal(
|
||||
<div
|
||||
ref={pickerMenuRef}
|
||||
className="tracklist-col-picker-menu"
|
||||
style={{ position: 'fixed', top: pickerPos.top, right: pickerPos.right, zIndex: 9999 }}
|
||||
>
|
||||
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||
{PL_COLUMNS.filter(c => !c.required).map(c => {
|
||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||
@@ -989,7 +1079,8 @@ export default function PlaylistDetail() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3756,6 +3756,30 @@ select.input.input:focus {
|
||||
/* Keep arrow on focus */
|
||||
}
|
||||
|
||||
/* ─── Input Search ─── */
|
||||
.input-search {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding-left: 36px;
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-overlay0);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ─── Card ─── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
|
||||
Reference in New Issue
Block a user