mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(now-playing): draggable widget cards with per-user layout (#267)
Each dashboard card (Album, Top Songs, Credits, Artist, Discography, On Tour) is now a widget the user grabs and drops anywhere — reorder within a column or move across columns. Layout persists per-install via the new nowPlayingLayoutStore (Zustand + localStorage, same shape as sidebarStore with an onRehydrateStorage guard for unknown IDs). Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event based system from DragDropContext) — HTML5 native DnD is a no-go on WebKitGTK Linux where it always shows a forbidden cursor. The whole card is the drag handle; the column listens via a document-level mousemove for the drop indicator and via a global psy-drop listener that reads the latest hovered column from a ref, so drops below the last card still land correctly regardless of column height. Visibility is toggled from a layout menu in the top-right of the dashboard — two sections (visible / hidden) plus a reset-to-defaults button. No hide buttons on the cards themselves (keeps the card UI uncluttered). An equal-height grid (align-items: stretch + min-height on columns) keeps the drop zone active for the full vertical span of either column. Co-authored-by: Psychotoxical <dev@psysonic.app> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
b4f31e0954
commit
db72ed9e5a
@@ -117,6 +117,13 @@ export const deTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (Auto)',
|
||||
showMoreTracks: '{{count}} weitere anzeigen',
|
||||
showLessTracks: 'Weniger anzeigen',
|
||||
hideCard: 'Karte ausblenden',
|
||||
layoutMenu: 'Layout',
|
||||
visibleCards: 'Sichtbare Karten',
|
||||
hiddenCards: 'Ausgeblendete Karten',
|
||||
noHiddenCards: 'Keine ausgeblendeten Karten',
|
||||
resetLayout: 'Layout zurücksetzen',
|
||||
emptyColumn: 'Karten hier ablegen',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Direkt abspielen',
|
||||
|
||||
@@ -118,6 +118,13 @@ export const enTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (auto)',
|
||||
showMoreTracks: 'Show {{count}} more',
|
||||
showLessTracks: 'Show less',
|
||||
hideCard: 'Hide card',
|
||||
layoutMenu: 'Layout',
|
||||
visibleCards: 'Visible cards',
|
||||
hiddenCards: 'Hidden cards',
|
||||
noHiddenCards: 'No hidden cards',
|
||||
resetLayout: 'Reset layout',
|
||||
emptyColumn: 'Drop cards here',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
|
||||
@@ -118,6 +118,13 @@ export const esTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (auto)',
|
||||
showMoreTracks: 'Mostrar {{count}} más',
|
||||
showLessTracks: 'Mostrar menos',
|
||||
hideCard: 'Ocultar tarjeta',
|
||||
layoutMenu: 'Diseño',
|
||||
visibleCards: 'Tarjetas visibles',
|
||||
hiddenCards: 'Tarjetas ocultas',
|
||||
noHiddenCards: 'No hay tarjetas ocultas',
|
||||
resetLayout: 'Restablecer diseño',
|
||||
emptyColumn: 'Suelta tarjetas aquí',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Reproducir Ahora',
|
||||
|
||||
@@ -117,6 +117,13 @@ export const frTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (auto)',
|
||||
showMoreTracks: 'Afficher {{count}} de plus',
|
||||
showLessTracks: 'Réduire',
|
||||
hideCard: 'Masquer la carte',
|
||||
layoutMenu: 'Disposition',
|
||||
visibleCards: 'Cartes visibles',
|
||||
hiddenCards: 'Cartes masquées',
|
||||
noHiddenCards: 'Aucune carte masquée',
|
||||
resetLayout: 'Réinitialiser la disposition',
|
||||
emptyColumn: 'Déposez des cartes ici',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Lire maintenant',
|
||||
|
||||
@@ -117,6 +117,13 @@ export const nbTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (auto)',
|
||||
showMoreTracks: 'Vis {{count}} til',
|
||||
showLessTracks: 'Vis mindre',
|
||||
hideCard: 'Skjul kort',
|
||||
layoutMenu: 'Oppsett',
|
||||
visibleCards: 'Synlige kort',
|
||||
hiddenCards: 'Skjulte kort',
|
||||
noHiddenCards: 'Ingen skjulte kort',
|
||||
resetLayout: 'Tilbakestill oppsett',
|
||||
emptyColumn: 'Slipp kort her',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Spill nå',
|
||||
|
||||
@@ -117,6 +117,13 @@ export const nlTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (auto)',
|
||||
showMoreTracks: '{{count}} meer tonen',
|
||||
showLessTracks: 'Minder tonen',
|
||||
hideCard: 'Kaart verbergen',
|
||||
layoutMenu: 'Lay-out',
|
||||
visibleCards: 'Zichtbare kaarten',
|
||||
hiddenCards: 'Verborgen kaarten',
|
||||
noHiddenCards: 'Geen verborgen kaarten',
|
||||
resetLayout: 'Lay-out resetten',
|
||||
emptyColumn: 'Sleep kaarten hier',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Nu afspelen',
|
||||
|
||||
@@ -122,6 +122,13 @@ export const ruTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (авто)',
|
||||
showMoreTracks: 'Показать ещё {{count}}',
|
||||
showLessTracks: 'Свернуть',
|
||||
hideCard: 'Скрыть карточку',
|
||||
layoutMenu: 'Макет',
|
||||
visibleCards: 'Видимые карточки',
|
||||
hiddenCards: 'Скрытые карточки',
|
||||
noHiddenCards: 'Нет скрытых карточек',
|
||||
resetLayout: 'Сбросить макет',
|
||||
emptyColumn: 'Перетащите карточки сюда',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Играть сейчас',
|
||||
|
||||
@@ -117,6 +117,13 @@ export const zhTranslation = {
|
||||
rgAutoTooltip: 'ReplayGain (自动)',
|
||||
showMoreTracks: '显示另外 {{count}} 首',
|
||||
showLessTracks: '收起',
|
||||
hideCard: '隐藏卡片',
|
||||
layoutMenu: '布局',
|
||||
visibleCards: '可见卡片',
|
||||
hiddenCards: '已隐藏的卡片',
|
||||
noHiddenCards: '没有已隐藏的卡片',
|
||||
resetLayout: '重置布局',
|
||||
emptyColumn: '将卡片拖到此处',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: '立即播放',
|
||||
|
||||
+311
-44
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play } from 'lucide-react';
|
||||
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -23,6 +23,11 @@ import { useCachedUrl } from '../components/CachedImage';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
|
||||
import {
|
||||
useNpLayoutStore, NP_CARD_IDS,
|
||||
type NpCardId, type NpColumn,
|
||||
} from '../store/nowPlayingLayoutStore';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -717,6 +722,84 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Widget wrapper (drag source via psyDnD) ─────────────────────────────────
|
||||
|
||||
interface NpCardWrapProps {
|
||||
id: NpCardId;
|
||||
label: string;
|
||||
isDraggingThis: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) {
|
||||
const dragProps = useDragSource(() => ({
|
||||
data: JSON.stringify({ kind: 'np-card', id }),
|
||||
label,
|
||||
}));
|
||||
return (
|
||||
<div
|
||||
data-np-wrapper
|
||||
data-np-card-id={id}
|
||||
className={`np-dash-card-wrap${isDraggingThis ? ' is-dragging' : ''}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Column (drop target via psy-drop + global mousemove) ────────────────────
|
||||
|
||||
interface NpColumnProps {
|
||||
col: NpColumn;
|
||||
children: React.ReactNode;
|
||||
empty: boolean;
|
||||
emptyLabel: string;
|
||||
isDndActive: boolean;
|
||||
draggingCardId: NpCardId | null;
|
||||
onHover: (col: NpColumn, idx: number) => void;
|
||||
isOverHere: boolean;
|
||||
}
|
||||
|
||||
function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggingCardId) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Use only the x-axis to decide "which column". This keeps the whole
|
||||
// vertical strip above / below the last card part of the drop zone,
|
||||
// so the user can drop "at the very bottom" of either column.
|
||||
if (e.clientX < rect.left || e.clientX > rect.right) return;
|
||||
const wrappers = Array.from(el.querySelectorAll<HTMLElement>('[data-np-wrapper]'))
|
||||
.filter(w => w.getAttribute('data-np-card-id') !== draggingCardId);
|
||||
let idx = wrappers.length;
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
const r = wrappers[i].getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { idx = i; break; }
|
||||
}
|
||||
onHover(col, idx);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
return () => document.removeEventListener('mousemove', onMove);
|
||||
}, [draggingCardId, col, onHover]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`np-dash-col${isOverHere ? ' is-drop-target' : ''}${isDndActive ? ' is-dnd-active' : ''}`}
|
||||
>
|
||||
{children}
|
||||
{empty && <div className="np-dash-col-empty">{emptyLabel}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
|
||||
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
|
||||
|
||||
@@ -1007,6 +1090,74 @@ export default function NowPlaying() {
|
||||
if (hit) playTrackFn(hit, queue);
|
||||
}, [topSongs, playTrackFn]);
|
||||
|
||||
// ── Widget layout (drag-to-reorder, hide/show, reset) ────────────────────
|
||||
const layoutCards = useNpLayoutStore(s => s.cards);
|
||||
const moveCard = useNpLayoutStore(s => s.moveCard);
|
||||
const setCardVisible = useNpLayoutStore(s => s.setVisible);
|
||||
const resetLayout = useNpLayoutStore(s => s.reset);
|
||||
const { isDragging: dndActive, payload: dndPayload } = useDragDrop();
|
||||
|
||||
const [dragOver, setDragOver] = useState<{ col: NpColumn; idx: number } | null>(null);
|
||||
const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
|
||||
|
||||
// Parse the current drag payload to know whether it's an np-card drag
|
||||
const draggingCardId: NpCardId | null = useMemo(() => {
|
||||
if (!dndActive || !dndPayload) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(dndPayload.data);
|
||||
if (parsed?.kind === 'np-card' && NP_CARD_IDS.includes(parsed.id)) return parsed.id as NpCardId;
|
||||
} catch { /* not a card payload */ }
|
||||
return null;
|
||||
}, [dndActive, dndPayload]);
|
||||
|
||||
// Clear the drop indicator when the drag ends (no psy-drop on our target)
|
||||
useEffect(() => { if (!draggingCardId) setDragOver(null); }, [draggingCardId]);
|
||||
|
||||
const toggleCardVisible = useCallback((id: NpCardId, next: boolean) => {
|
||||
setCardVisible(id, next);
|
||||
}, [setCardVisible]);
|
||||
|
||||
const onColumnHover = useCallback((col: NpColumn, idx: number) => {
|
||||
setDragOver(prev => (prev && prev.col === col && prev.idx === idx) ? prev : { col, idx });
|
||||
}, []);
|
||||
|
||||
// Ref mirror of dragOver so the document-level psy-drop handler always sees
|
||||
// the latest hovered column/index regardless of closure timing.
|
||||
const dragOverRef = useRef(dragOver);
|
||||
dragOverRef.current = dragOver;
|
||||
|
||||
// Global psy-drop listener: catches drops anywhere on the page (even below a
|
||||
// column when the cursor left the column bounds), then uses dragOverRef to
|
||||
// decide which column/index the user actually meant.
|
||||
useEffect(() => {
|
||||
if (!draggingCardId) return;
|
||||
const onPsyDrop = (evt: Event) => {
|
||||
const ce = evt as CustomEvent<{ data: string }>;
|
||||
try {
|
||||
const parsed = JSON.parse(ce.detail?.data ?? '');
|
||||
if (parsed?.kind !== 'np-card' || !NP_CARD_IDS.includes(parsed.id)) return;
|
||||
const over = dragOverRef.current;
|
||||
if (over) {
|
||||
moveCard(parsed.id as NpCardId, over.col, over.idx);
|
||||
}
|
||||
} catch { /* ignore non-card drops */ }
|
||||
setDragOver(null);
|
||||
};
|
||||
document.addEventListener('psy-drop', onPsyDrop as EventListener);
|
||||
return () => document.removeEventListener('psy-drop', onPsyDrop as EventListener);
|
||||
}, [draggingCardId, moveCard]);
|
||||
|
||||
// Close layout menu on outside click
|
||||
useEffect(() => {
|
||||
if (!layoutMenuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (!el?.closest('.np-dash-toolbar-menu-wrap')) setLayoutMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [layoutMenuOpen]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="np-page">
|
||||
@@ -1047,49 +1198,165 @@ export default function NowPlaying() {
|
||||
onOpenLyrics={openLyrics}
|
||||
/>
|
||||
|
||||
<div className="np-dash-grid">
|
||||
<div className="np-dash-col">
|
||||
<AlbumCard
|
||||
album={albumData?.album ?? null}
|
||||
songs={albumData?.songs ?? []}
|
||||
currentTrackId={currentTrack.id}
|
||||
albumName={currentTrack.album}
|
||||
albumId={albumId}
|
||||
albumYear={currentTrack.year}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
<TopSongsCard
|
||||
artistName={artistName}
|
||||
artistId={artistId}
|
||||
songs={topSongs}
|
||||
currentTrackId={currentTrack.id}
|
||||
onNavigate={stableNavigate}
|
||||
onPlay={handlePlayTopSong}
|
||||
/>
|
||||
<CreditsCard rows={contributorRows} />
|
||||
</div>
|
||||
<div className="np-dash-col">
|
||||
<ArtistCard
|
||||
artistName={artistName}
|
||||
artistId={artistId}
|
||||
artistInfo={effectiveArtistInfo}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
<DiscographyCard
|
||||
artistId={artistId}
|
||||
albums={discography}
|
||||
currentAlbumId={albumId}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
<TourCard
|
||||
artistName={artistName}
|
||||
enabled={enableBandsintown}
|
||||
loading={tourLoading}
|
||||
events={tourEvents}
|
||||
onEnable={handleEnableBandsintown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const renderCard = (id: NpCardId): React.ReactNode => {
|
||||
switch (id) {
|
||||
case 'album': return (
|
||||
<AlbumCard
|
||||
album={albumData?.album ?? null}
|
||||
songs={albumData?.songs ?? []}
|
||||
currentTrackId={currentTrack.id}
|
||||
albumName={currentTrack.album}
|
||||
albumId={albumId}
|
||||
albumYear={currentTrack.year}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
);
|
||||
case 'topSongs': return (
|
||||
<TopSongsCard
|
||||
artistName={artistName}
|
||||
artistId={artistId}
|
||||
songs={topSongs}
|
||||
currentTrackId={currentTrack.id}
|
||||
onNavigate={stableNavigate}
|
||||
onPlay={handlePlayTopSong}
|
||||
/>
|
||||
);
|
||||
case 'credits': return <CreditsCard rows={contributorRows} />;
|
||||
case 'artist': return (
|
||||
<ArtistCard
|
||||
artistName={artistName}
|
||||
artistId={artistId}
|
||||
artistInfo={effectiveArtistInfo}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
);
|
||||
case 'discography': return (
|
||||
<DiscographyCard
|
||||
artistId={artistId}
|
||||
albums={discography}
|
||||
currentAlbumId={albumId}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
);
|
||||
case 'tour': return (
|
||||
<TourCard
|
||||
artistName={artistName}
|
||||
enabled={enableBandsintown}
|
||||
loading={tourLoading}
|
||||
events={tourEvents}
|
||||
onEnable={handleEnableBandsintown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
const cardLabel = (id: NpCardId): string => {
|
||||
const k: Record<NpCardId, string> = {
|
||||
album: 'nowPlaying.fromAlbum',
|
||||
topSongs: 'nowPlaying.topSongs',
|
||||
credits: 'nowPlayingInfo.songInfo',
|
||||
artist: 'nowPlaying.aboutArtist',
|
||||
discography: 'nowPlaying.discography',
|
||||
tour: 'nowPlayingInfo.onTour',
|
||||
};
|
||||
return t(k[id]);
|
||||
};
|
||||
const visibleCards = layoutCards.filter(c => c.visible);
|
||||
const hiddenCards = layoutCards.filter(c => !c.visible);
|
||||
const renderColumn = (col: NpColumn) => {
|
||||
const cards = layoutCards.filter(c =>
|
||||
c.column === col && c.visible && c.id !== draggingCardId,
|
||||
);
|
||||
const isOver = dragOver?.col === col;
|
||||
return (
|
||||
<NpColumnEl
|
||||
col={col}
|
||||
empty={cards.length === 0}
|
||||
emptyLabel={t('nowPlaying.emptyColumn', 'Drop cards here')}
|
||||
isDndActive={!!draggingCardId}
|
||||
draggingCardId={draggingCardId}
|
||||
onHover={onColumnHover}
|
||||
isOverHere={!!isOver}
|
||||
>
|
||||
{cards.map((c, idx) => (
|
||||
<React.Fragment key={c.id}>
|
||||
{isOver && dragOver.idx === idx && <div className="np-dash-drop-indicator" />}
|
||||
<NpCardWrap
|
||||
id={c.id}
|
||||
label={cardLabel(c.id)}
|
||||
isDraggingThis={draggingCardId === c.id}
|
||||
>
|
||||
{renderCard(c.id)}
|
||||
</NpCardWrap>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{isOver && dragOver.idx === cards.length && <div className="np-dash-drop-indicator" />}
|
||||
</NpColumnEl>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="np-dash-toolbar">
|
||||
<div className="np-dash-toolbar-menu-wrap">
|
||||
<button
|
||||
className="np-dash-toolbar-btn"
|
||||
onClick={() => setLayoutMenuOpen(v => !v)}
|
||||
data-tooltip={t('nowPlaying.layoutMenu', 'Layout')}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
<span>{t('nowPlaying.layoutMenu', 'Layout')}</span>
|
||||
{hiddenCards.length > 0 && (
|
||||
<span className="np-dash-toolbar-badge">{hiddenCards.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{layoutMenuOpen && (
|
||||
<div className="np-dash-toolbar-menu" role="menu">
|
||||
<div className="np-dash-toolbar-section">
|
||||
{t('nowPlaying.visibleCards', 'Visible cards')}
|
||||
</div>
|
||||
{visibleCards.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
className="np-dash-toolbar-item"
|
||||
onClick={() => toggleCardVisible(c.id, false)}
|
||||
>
|
||||
<Eye size={13} /> <span className="np-dash-toolbar-item-label">{cardLabel(c.id)}</span>
|
||||
</button>
|
||||
))}
|
||||
{hiddenCards.length > 0 && (
|
||||
<>
|
||||
<div className="np-dash-toolbar-section">
|
||||
{t('nowPlaying.hiddenCards', 'Hidden cards')}
|
||||
</div>
|
||||
{hiddenCards.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
className="np-dash-toolbar-item is-hidden"
|
||||
onClick={() => toggleCardVisible(c.id, true)}
|
||||
>
|
||||
<EyeOff size={13} /> <span className="np-dash-toolbar-item-label">{cardLabel(c.id)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="np-dash-toolbar-divider" />
|
||||
<button
|
||||
className="np-dash-toolbar-item"
|
||||
onClick={() => { resetLayout(); setLayoutMenuOpen(false); }}
|
||||
>
|
||||
<RotateCcw size={13} /> <span className="np-dash-toolbar-item-label">{t('nowPlaying.resetLayout', 'Reset layout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="np-dash-grid">
|
||||
{renderColumn('left')}
|
||||
{renderColumn('right')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type NpColumn = 'left' | 'right';
|
||||
|
||||
export type NpCardId =
|
||||
| 'album'
|
||||
| 'topSongs'
|
||||
| 'credits'
|
||||
| 'artist'
|
||||
| 'discography'
|
||||
| 'tour';
|
||||
|
||||
export interface NpCardConfig {
|
||||
id: NpCardId;
|
||||
column: NpColumn;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const NP_CARD_IDS: NpCardId[] = ['album', 'topSongs', 'credits', 'artist', 'discography', 'tour'];
|
||||
|
||||
export const DEFAULT_NP_LAYOUT: NpCardConfig[] = [
|
||||
{ id: 'album', column: 'left', visible: true },
|
||||
{ id: 'topSongs', column: 'left', visible: true },
|
||||
{ id: 'credits', column: 'left', visible: true },
|
||||
{ id: 'artist', column: 'right', visible: true },
|
||||
{ id: 'discography', column: 'right', visible: true },
|
||||
{ id: 'tour', column: 'right', visible: true },
|
||||
];
|
||||
|
||||
interface NpLayoutStore {
|
||||
cards: NpCardConfig[];
|
||||
/** Move a card to a column at a given insertion index (0-based within that column). */
|
||||
moveCard: (id: NpCardId, toColumn: NpColumn, toIndex: number) => void;
|
||||
setVisible: (id: NpCardId, visible: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useNpLayoutStore = create<NpLayoutStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
cards: DEFAULT_NP_LAYOUT,
|
||||
|
||||
moveCard: (id, toColumn, toIndex) => set((s) => {
|
||||
const target = s.cards.find(c => c.id === id);
|
||||
if (!target) return s;
|
||||
const without = s.cards.filter(c => c.id !== id);
|
||||
const left = without.filter(c => c.column === 'left');
|
||||
const right = without.filter(c => c.column === 'right');
|
||||
const moved: NpCardConfig = { ...target, column: toColumn };
|
||||
const targetBucket = toColumn === 'left' ? left : right;
|
||||
const clamped = Math.max(0, Math.min(toIndex, targetBucket.length));
|
||||
targetBucket.splice(clamped, 0, moved);
|
||||
return { cards: [...left, ...right] };
|
||||
}),
|
||||
|
||||
setVisible: (id, visible) => set((s) => ({
|
||||
cards: s.cards.map(c => c.id === id ? { ...c, visible } : c),
|
||||
})),
|
||||
|
||||
reset: () => set({ cards: DEFAULT_NP_LAYOUT }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_np_layout',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
const safe = (state.cards ?? []).filter((c): c is NpCardConfig =>
|
||||
c != null && typeof c.id === 'string' && NP_CARD_IDS.includes(c.id as NpCardId)
|
||||
);
|
||||
const known = new Set(safe.map(c => c.id));
|
||||
const missing = DEFAULT_NP_LAYOUT.filter(c => !known.has(c.id));
|
||||
state.cards = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
+131
-1
@@ -10364,11 +10364,137 @@ html[data-app-hidden="true"] *::after {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Widget layout toolbar (layout / hidden-cards menu) */
|
||||
.np-dash-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
.np-dash-toolbar-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.np-dash-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.np-dash-toolbar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-dash-toolbar-badge {
|
||||
background: var(--accent);
|
||||
color: var(--ctp-base, #1e1e2e);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.np-dash-toolbar-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--ctp-base, #1e1e2e);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
z-index: 50;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.np-dash-toolbar-section {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
.np-dash-toolbar-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.50);
|
||||
font-style: italic;
|
||||
}
|
||||
.np-dash-toolbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12.5px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.np-dash-toolbar-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--accent);
|
||||
}
|
||||
.np-dash-toolbar-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Widget wrapper — makes each card a psyDnD drag source */
|
||||
.np-dash-card-wrap {
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
}
|
||||
.np-dash-col.is-dnd-active .np-dash-card-wrap {
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.np-dash-col.is-drop-target {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-lg);
|
||||
outline: 1px dashed rgba(255, 255, 255, 0.10);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.np-dash-toolbar-item-label { flex: 1; min-width: 0; }
|
||||
.np-dash-toolbar-item.is-hidden .np-dash-toolbar-item-label { opacity: 0.55; }
|
||||
|
||||
/* Drop indicator — accent line between cards while dragging */
|
||||
.np-dash-drop-indicator {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
margin: -8px 0;
|
||||
}
|
||||
|
||||
/* Empty column placeholder */
|
||||
.np-dash-col-empty {
|
||||
padding: 28px 18px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: rgba(255, 255, 255, 0.40);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.10);
|
||||
border-radius: var(--radius-lg);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Two flex columns — left stack, right stack */
|
||||
.np-dash-grid {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
align-items: stretch; /* equal-height columns so drops below the last card still hit the column */
|
||||
}
|
||||
|
||||
.np-dash-col {
|
||||
@@ -10377,6 +10503,10 @@ html[data-app-hidden="true"] *::after {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
/* Ensures the area below the last card still belongs to this column — needed
|
||||
so the user can drop a card "at the very bottom" even when the other
|
||||
column is taller. */
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.np-dash-card {
|
||||
|
||||
Reference in New Issue
Block a user