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:
Frank Stellmacher
2026-04-22 21:14:13 +02:00
committed by GitHub
parent b4f31e0954
commit db72ed9e5a
11 changed files with 574 additions and 45 deletions
+7
View File
@@ -117,6 +117,13 @@ export const deTranslation = {
rgAutoTooltip: 'ReplayGain (Auto)', rgAutoTooltip: 'ReplayGain (Auto)',
showMoreTracks: '{{count}} weitere anzeigen', showMoreTracks: '{{count}} weitere anzeigen',
showLessTracks: 'Weniger 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: { contextMenu: {
playNow: 'Direkt abspielen', playNow: 'Direkt abspielen',
+7
View File
@@ -118,6 +118,13 @@ export const enTranslation = {
rgAutoTooltip: 'ReplayGain (auto)', rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Show {{count}} more', showMoreTracks: 'Show {{count}} more',
showLessTracks: 'Show less', 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: { contextMenu: {
playNow: 'Play Now', playNow: 'Play Now',
+7
View File
@@ -118,6 +118,13 @@ export const esTranslation = {
rgAutoTooltip: 'ReplayGain (auto)', rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Mostrar {{count}} más', showMoreTracks: 'Mostrar {{count}} más',
showLessTracks: 'Mostrar menos', 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: { contextMenu: {
playNow: 'Reproducir Ahora', playNow: 'Reproducir Ahora',
+7
View File
@@ -117,6 +117,13 @@ export const frTranslation = {
rgAutoTooltip: 'ReplayGain (auto)', rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Afficher {{count}} de plus', showMoreTracks: 'Afficher {{count}} de plus',
showLessTracks: 'Réduire', 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: { contextMenu: {
playNow: 'Lire maintenant', playNow: 'Lire maintenant',
+7
View File
@@ -117,6 +117,13 @@ export const nbTranslation = {
rgAutoTooltip: 'ReplayGain (auto)', rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Vis {{count}} til', showMoreTracks: 'Vis {{count}} til',
showLessTracks: 'Vis mindre', 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: { contextMenu: {
playNow: 'Spill nå', playNow: 'Spill nå',
+7
View File
@@ -117,6 +117,13 @@ export const nlTranslation = {
rgAutoTooltip: 'ReplayGain (auto)', rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: '{{count}} meer tonen', showMoreTracks: '{{count}} meer tonen',
showLessTracks: 'Minder 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: { contextMenu: {
playNow: 'Nu afspelen', playNow: 'Nu afspelen',
+7
View File
@@ -122,6 +122,13 @@ export const ruTranslation = {
rgAutoTooltip: 'ReplayGain (авто)', rgAutoTooltip: 'ReplayGain (авто)',
showMoreTracks: 'Показать ещё {{count}}', showMoreTracks: 'Показать ещё {{count}}',
showLessTracks: 'Свернуть', showLessTracks: 'Свернуть',
hideCard: 'Скрыть карточку',
layoutMenu: 'Макет',
visibleCards: 'Видимые карточки',
hiddenCards: 'Скрытые карточки',
noHiddenCards: 'Нет скрытых карточек',
resetLayout: 'Сбросить макет',
emptyColumn: 'Перетащите карточки сюда',
}, },
contextMenu: { contextMenu: {
playNow: 'Играть сейчас', playNow: 'Играть сейчас',
+7
View File
@@ -117,6 +117,13 @@ export const zhTranslation = {
rgAutoTooltip: 'ReplayGain (自动)', rgAutoTooltip: 'ReplayGain (自动)',
showMoreTracks: '显示另外 {{count}} 首', showMoreTracks: '显示另外 {{count}} 首',
showLessTracks: '收起', showLessTracks: '收起',
hideCard: '隐藏卡片',
layoutMenu: '布局',
visibleCards: '可见卡片',
hiddenCards: '已隐藏的卡片',
noHiddenCards: '没有已隐藏的卡片',
resetLayout: '重置布局',
emptyColumn: '将卡片拖到此处',
}, },
contextMenu: { contextMenu: {
playNow: '立即播放', playNow: '立即播放',
+311 -44
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -23,6 +23,11 @@ import { useCachedUrl } from '../components/CachedImage';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata'; import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import {
useNpLayoutStore, NP_CARD_IDS,
type NpCardId, type NpColumn,
} from '../store/nowPlayingLayoutStore';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── 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>> = type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>; NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
@@ -1007,6 +1090,74 @@ export default function NowPlaying() {
if (hit) playTrackFn(hit, queue); if (hit) playTrackFn(hit, queue);
}, [topSongs, playTrackFn]); }, [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 ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<div className="np-page"> <div className="np-page">
@@ -1047,49 +1198,165 @@ export default function NowPlaying() {
onOpenLyrics={openLyrics} onOpenLyrics={openLyrics}
/> />
<div className="np-dash-grid"> {(() => {
<div className="np-dash-col"> const renderCard = (id: NpCardId): React.ReactNode => {
<AlbumCard switch (id) {
album={albumData?.album ?? null} case 'album': return (
songs={albumData?.songs ?? []} <AlbumCard
currentTrackId={currentTrack.id} album={albumData?.album ?? null}
albumName={currentTrack.album} songs={albumData?.songs ?? []}
albumId={albumId} currentTrackId={currentTrack.id}
albumYear={currentTrack.year} albumName={currentTrack.album}
onNavigate={stableNavigate} albumId={albumId}
/> albumYear={currentTrack.year}
<TopSongsCard onNavigate={stableNavigate}
artistName={artistName} />
artistId={artistId} );
songs={topSongs} case 'topSongs': return (
currentTrackId={currentTrack.id} <TopSongsCard
onNavigate={stableNavigate} artistName={artistName}
onPlay={handlePlayTopSong} artistId={artistId}
/> songs={topSongs}
<CreditsCard rows={contributorRows} /> currentTrackId={currentTrack.id}
</div> onNavigate={stableNavigate}
<div className="np-dash-col"> onPlay={handlePlayTopSong}
<ArtistCard />
artistName={artistName} );
artistId={artistId} case 'credits': return <CreditsCard rows={contributorRows} />;
artistInfo={effectiveArtistInfo} case 'artist': return (
onNavigate={stableNavigate} <ArtistCard
/> artistName={artistName}
<DiscographyCard artistId={artistId}
artistId={artistId} artistInfo={effectiveArtistInfo}
albums={discography} onNavigate={stableNavigate}
currentAlbumId={albumId} />
onNavigate={stableNavigate} );
/> case 'discography': return (
<TourCard <DiscographyCard
artistName={artistName} artistId={artistId}
enabled={enableBandsintown} albums={discography}
loading={tourLoading} currentAlbumId={albumId}
events={tourEvents} onNavigate={stableNavigate}
onEnable={handleEnableBandsintown} />
/> );
</div> case 'tour': return (
</div> <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>
) : ( ) : (
<div className="np-empty-state"> <div className="np-empty-state">
+76
View File
@@ -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
View File
@@ -10364,11 +10364,137 @@ html[data-app-hidden="true"] *::after {
font-weight: 600; 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 */ /* Two flex columns — left stack, right stack */
.np-dash-grid { .np-dash-grid {
display: flex; display: flex;
gap: 18px; 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 { .np-dash-col {
@@ -10377,6 +10503,10 @@ html[data-app-hidden="true"] *::after {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; 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 { .np-dash-card {