feat: auth & admin

This commit is contained in:
2026-06-03 10:41:53 +03:00
parent 612d0f0125
commit 7dc59fb3c4
120 changed files with 4683 additions and 2159 deletions
+155 -46
View File
@@ -1,60 +1,169 @@
import { ScrollArea, IconButton, Badge } from 'modern-sk';
import { Slider, Badge } from 'modern-sk';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { goToIndex, removeFromQueue, clearQueue } from '../../store/slices/queue';
import {
goToIndex,
removeFromQueue,
clearQueue,
} from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player';
import { formatDuration } from '../../lib/format';
export function QueuePanel() {
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
if (!isOpen) return null;
const now =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source;
return (
<div style={{ width: '20rem', borderLeft: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', background: 'var(--color-surface-1)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', borderBottom: '1px solid var(--color-border)' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>Queue</span>
<div style={{ display: 'flex', gap: '0.25rem' }}>
{queue.sourceName && <Badge variant="neutral">{queue.sourceName}</Badge>}
<IconButton variant="ghost" size="sm" onClick={() => dispatch(clearQueue())} aria-label="Clear queue"></IconButton>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Close"></IconButton>
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
>
<Icon name="trash" />
</button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
>
<Icon name="x" />
</button>
</div>
<div className="qd-src">
<Icon
name={isRadio ? 'radio' : 'playlist'}
style={{ color: isRadio ? 'var(--lime)' : 'var(--fg-3)' }}
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
</span>
) : (
<span>From {sourceLabel}</span>
)}
</div>
</div>
<div className="qd-scroll">
{now ? (
<>
<span
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
</span>
<div className="qd-now">
<ArtTile
seed={now.albumTitle}
size={44}
label={now.albumTitle}
/>
<div className="qt">
<div className="t">{now.title}</div>
<div className="r">{now.artistName}</div>
</div>
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
</div>
{isRadio && (
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
Radio active
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
</div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<Slider
className="expl-slider"
min={0}
max={100}
step={1}
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
</div>
</div>
)}
<span
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
) : (
upNext.map(({ entry, index }) => (
<div
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
>
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile
seed={entry.albumTitle}
size={36}
label={entry.albumTitle}
/>
<div className="qt">
<div className="t">{entry.title}</div>
<div className="r">{entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
>
<Icon name="x" />
</button>
</div>
))
)}
{isRadio && (
<div className="qd-loadmore">Loading more from radio</div>
)}
</>
) : (
<div className="qd-empty">Queue is empty</div>
)}
</div>
</div>
<ScrollArea style={{ flex: 1 }}>
{queue.entries.length === 0 ? (
<p style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>Queue is empty</p>
) : (
queue.entries.map((entry, i) => (
<div
key={`${entry.trackId}-${i}`}
onDoubleClick={() => dispatch(goToIndex(i))}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
padding: '0.5rem 1rem',
gap: '0.5rem',
alignItems: 'center',
background: i === queue.currentIndex ? 'var(--color-surface-2)' : undefined,
cursor: 'default',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.8125rem', fontWeight: i === queue.currentIndex ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: i === queue.currentIndex ? 'var(--color-accent)' : 'var(--color-text-1)' }}>
{entry.title}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.artistName}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', flexShrink: 0 }}>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{formatDuration(entry.durationMs)}</span>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(removeFromQueue(i))} aria-label="Remove from queue"></IconButton>
</div>
</div>
))
)}
</ScrollArea>
</div>
</aside>
);
}