8ae447e08d
Replace the labelled availability/metadata badges in track rows with small icon+tooltip indicators (cloud/hard-drives/warning/etc, derived from TrackAvailability and MetadataStatus). Add a `connection` slice fed by a single status poller (Sidebar) so other components can cheaply check backend reachability. TrackRow uses this plus the offline audio cache to show "Local" instead of a stale "On server" when the backend is down but the track is already cached.
160 lines
5.2 KiB
TypeScript
160 lines
5.2 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>
|
|
);
|
|
}
|