feat: auth & admin
This commit is contained in:
@@ -1,113 +1,147 @@
|
||||
import { Badge } from 'modern-sk';
|
||||
import { NavLink, useNavigate } from 'react-router';
|
||||
import { ConnectionStatus } from '../common/ConnectionStatus';
|
||||
import { useAppSelector, useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { Icon, type IconName } from '../common/Icon';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import { logout } from '../../store/slices/auth';
|
||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||
import { getActiveInstance } from '../../config/instances';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Home', icon: '⌂' },
|
||||
{ to: '/library', label: 'Library', icon: '♫' },
|
||||
{ to: '/search', label: 'Search & Download', icon: '⊕' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: '↓' },
|
||||
{ to: '/storage', label: 'Storage', icon: '⊞' },
|
||||
{ to: '/settings', label: 'Settings', icon: '⚙' },
|
||||
] as const;
|
||||
interface NavDef {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
const ADMIN_ITEMS = [
|
||||
{ to: '/admin', label: 'Admin', icon: '🔑' },
|
||||
] as const;
|
||||
const MAIN_NAV: NavDef[] = [
|
||||
{ to: '/', label: 'Home', icon: 'house', end: true },
|
||||
{ to: '/library', label: 'Library', icon: 'vinyl-record' },
|
||||
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
|
||||
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
|
||||
{ to: '/storage', label: 'Storage', icon: 'hard-drives' },
|
||||
];
|
||||
|
||||
const CONN_CLASS: Record<string, { cls: string; txt: string }> = {
|
||||
connected: { cls: 'online', txt: 'Connected' },
|
||||
connecting: { cls: 'syncing', txt: 'Connecting…' },
|
||||
disconnected: { cls: 'offline', txt: 'Offline' },
|
||||
error: { cls: 'error', txt: 'Unreachable' },
|
||||
};
|
||||
|
||||
function navClass({ isActive }: { isActive: boolean }) {
|
||||
return isActive ? 'nav-item active' : 'nav-item';
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAdmin } = usePermissions();
|
||||
const collapsed = useAppSelector((s) => s.ui.sidebarCollapsed);
|
||||
const status = useConnectionStatus();
|
||||
const { data: playlists } = useGetPlaylistsQuery();
|
||||
const instance = getActiveInstance();
|
||||
|
||||
const handleLogout = () => {
|
||||
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
|
||||
const online = status === 'connected';
|
||||
|
||||
const handleLogout = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(logout());
|
||||
void navigate('/connect');
|
||||
};
|
||||
|
||||
return (
|
||||
<nav style={{ width: collapsed ? '3.5rem' : '14rem', flexShrink: 0, borderRight: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', padding: '0.75rem 0', background: 'var(--color-surface-1)', transition: 'width 0.2s', overflow: 'hidden' }}>
|
||||
<div style={{ padding: collapsed ? '0 0.5rem' : '0 0.75rem', marginBottom: '1.5rem' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '1.125rem', color: 'var(--color-accent)', whiteSpace: 'nowrap' }}>
|
||||
{collapsed ? '♫' : '♫ MCMA'}
|
||||
</span>
|
||||
</div>
|
||||
<aside className="sidebar">
|
||||
<div className="sb-scroll">
|
||||
<div className="sb-brand">
|
||||
<Icon name="vinyl-record" fill />
|
||||
<span>{instance?.name ?? 'MCMA'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{NAV_ITEMS.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
|
||||
borderRadius: 6,
|
||||
margin: '0 0.375rem',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
|
||||
background: isActive ? 'var(--color-surface-2)' : undefined,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textDecoration: 'none',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.1s, color 0.1s',
|
||||
})}
|
||||
<div className="sb-sec">
|
||||
{MAIN_NAV.map(({ to, label, icon, end }) => (
|
||||
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sb-sec">
|
||||
<span className="msk-label">Playlists</span>
|
||||
{(playlists?.items ?? []).map((pl) => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/library/playlists/${pl.id}`}
|
||||
className={navClass}
|
||||
>
|
||||
<Icon name="playlist" />
|
||||
<span className="pl-name">{pl.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="pl-item"
|
||||
onClick={() => void navigate('/library')}
|
||||
>
|
||||
<span style={{ flexShrink: 0, fontSize: '1rem' }}>{icon}</span>
|
||||
{!collapsed && label}
|
||||
</NavLink>
|
||||
))}
|
||||
<Icon name="plus" />
|
||||
<span className="pl-name">New playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '0.5rem 0.75rem' }} />
|
||||
{ADMIN_ITEMS.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
|
||||
borderRadius: 6,
|
||||
margin: '0 0.375rem',
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
|
||||
background: isActive ? 'var(--color-surface-2)' : undefined,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textDecoration: 'none',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
<span style={{ flexShrink: 0 }}>{icon}</span>
|
||||
{!collapsed && label}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: collapsed ? '0 0.5rem' : '0 0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{!collapsed && <ConnectionStatus />}
|
||||
{!collapsed && user && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.5rem 0.25rem' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-1)' }}>{user.username}</div>
|
||||
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'} style={{ marginTop: 2 }}>{user.role}</Badge>
|
||||
</div>
|
||||
<button onClick={handleLogout} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-3)', fontSize: '0.75rem', padding: '0.25rem' }}>
|
||||
Sign out
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<div className="sb-sec">
|
||||
<span className="msk-label">Administration</span>
|
||||
<NavLink to="/admin" className={navClass}>
|
||||
<Icon name="shield-check" />
|
||||
<span>Admin</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={navClass}>
|
||||
<Icon name="gear-six" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sb-sec">
|
||||
<NavLink to="/settings" className={navClass}>
|
||||
<Icon name="gear-six" />
|
||||
<span>Settings</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sb-foot">
|
||||
<button
|
||||
type="button"
|
||||
className={`conn ${conn.cls}`}
|
||||
onClick={() => void navigate('/connect')}
|
||||
title="Connection — manage instances"
|
||||
>
|
||||
<span className="led" />
|
||||
{conn.txt}
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
className="user-chip"
|
||||
onClick={() => void navigate('/settings')}
|
||||
>
|
||||
<div className="user-av">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="nm">{user.username}</div>
|
||||
<div className="rl">
|
||||
{user.role} · {online ? 'online' : 'offline'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="uc-action" onClick={handleLogout} title="Sign out">
|
||||
<Icon name="sign-out" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user