Files
mcma-webui/src/components/layout/Sidebar.tsx
T
Senko-san 231887c3b7 feat(discover): wire A4 search + A5 downloads to backend
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:04:43 +03:00

175 lines
5.3 KiB
TypeScript

import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances';
interface NavDef {
to: string;
labelKey: string;
icon: IconName;
end?: boolean;
/** Hide this item unless the user holds the permission. */
perm?: Permission;
}
const MAIN_NAV: NavDef[] = [
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
{
to: '/discover',
labelKey: 'nav.search',
icon: 'magnifying-glass',
perm: 'download',
},
{
to: '/downloads',
labelKey: 'nav.downloads',
icon: 'arrow-circle-down',
perm: 'download',
},
{
to: '/upload',
labelKey: 'nav.upload',
icon: 'upload-simple',
perm: 'upload',
},
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
];
const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
connected: { cls: 'online', txtKey: 'conn.connected' },
connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
error: { cls: 'error', txtKey: 'conn.error' },
};
function navClass({ isActive }: { isActive: boolean }) {
return isActive ? 'nav-item active' : 'nav-item';
}
export function Sidebar() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatusSync();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(logout());
void navigate('/connect');
};
return (
<aside className="sidebar">
<div className="sb-scroll">
<div className="sb-brand">
<Icon name="vinyl-record" fill />
<span>{instance?.name ?? 'MCMA'}</span>
</div>
<div className="sb-sec">
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
({ to, labelKey, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} />
<span>{t(labelKey)}</span>
</NavLink>
),
)}
</div>
<div className="sb-sec">
<NavLink to="/playlists" end className="msk-label sb-sec-link">
{t('nav.playlists')}
</NavLink>
{(playlists?.items ?? []).map((pl) => (
<NavLink
key={pl.id}
to={`/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('/playlists')}
>
<Icon name="plus" />
<span className="pl-name">{t('nav.newPlaylist')}</span>
</button>
</div>
{isAdmin ? (
<div className="sb-sec">
<span className="msk-label">{t('nav.administration')}</span>
<NavLink to="/admin" className={navClass}>
<Icon name="shield-check" />
<span>{t('nav.admin')}</span>
</NavLink>
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>{t('nav.settings')}</span>
</NavLink>
</div>
) : (
<div className="sb-sec">
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>{t('nav.settings')}</span>
</NavLink>
</div>
)}
</div>
<div className="sb-foot">
<button
type="button"
className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')}
title={t('conn.manage')}
>
<span className="led" />
{t(conn.txtKey)}
</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 ? t('user.online') : t('user.offline')}
</div>
</div>
<span
className="uc-action"
onClick={handleLogout}
title={t('user.signOut')}
>
<Icon name="sign-out" />
</span>
</button>
)}
</div>
</aside>
);
}