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
+41 -20
View File
@@ -1,4 +1,9 @@
import { fetchBaseQuery, type BaseQueryFn, type FetchArgs, type FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import {
fetchBaseQuery,
type BaseQueryFn,
type FetchArgs,
type FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import type { RootState } from '../store';
import { getApiBaseUrl } from '../config/runtime-config';
import { logout, setTokens } from '../store/slices/auth';
@@ -13,33 +18,49 @@ const rawBaseQuery = () =>
},
});
export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
async (args, api, extraOptions) => {
let result = await rawBaseQuery()(args, api, extraOptions);
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await rawBaseQuery()(args, api, extraOptions);
if (result.error?.status === 401) {
const state = api.getState() as RootState;
const refreshToken = state.auth.refreshToken;
if (result.error?.status === 401) {
const state = api.getState() as RootState;
const refreshToken = state.auth.refreshToken;
if (refreshToken) {
const refreshResult = await rawBaseQuery()({
if (refreshToken) {
const refreshResult = await rawBaseQuery()(
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
}, api, extraOptions);
},
api,
extraOptions,
);
if (refreshResult.data) {
const { accessToken, refreshToken: newRefresh, expiresIn } =
(refreshResult.data as { accessToken: string; refreshToken: string; expiresIn: number });
api.dispatch(setTokens({ accessToken, refreshToken: newRefresh, expiresIn }));
result = await rawBaseQuery()(args, api, extraOptions);
} else {
api.dispatch(logout());
}
if (refreshResult.data) {
const {
accessToken,
refreshToken: newRefresh,
expiresIn,
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
);
result = await rawBaseQuery()(args, api, extraOptions);
} else {
api.dispatch(logout());
}
} else {
api.dispatch(logout());
}
}
return result;
};
return result;
};
+24 -4
View File
@@ -7,12 +7,27 @@ export const adminApi = api.injectEndpoints({
query: () => '/admin/users',
providesTags: ['User'],
}),
createUser: build.mutation<User, { username: string; password: string; email?: string; role: 'admin' | 'user' }>({
createUser: build.mutation<
User,
{
username: string;
password: string;
email?: string;
role: 'admin' | 'user';
}
>({
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
invalidatesTags: ['User'],
}),
updateUser: build.mutation<User, { id: string; role?: 'admin' | 'user'; email?: string }>({
query: ({ id, ...body }) => ({ url: `/admin/users/${id}`, method: 'PATCH', body }),
updateUser: build.mutation<
User,
{ id: string; role?: 'admin' | 'user'; email?: string }
>({
query: ({ id, ...body }) => ({
url: `/admin/users/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
@@ -23,4 +38,9 @@ export const adminApi = api.injectEndpoints({
overrideExisting: false,
});
export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation, useDeleteUserMutation } = adminApi;
export const {
useGetUsersQuery,
useCreateUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = adminApi;
+4 -1
View File
@@ -9,7 +9,10 @@ export const authApi = api.injectEndpoints({
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
refreshToken: build.mutation<{ accessToken: string; refreshToken: string; expiresIn: number }, { refreshToken: string }>({
refreshToken: build.mutation<
{ accessToken: string; refreshToken: string; expiresIn: number },
{ refreshToken: string }
>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
me: build.query<import('../types').User, void>({
+17 -3
View File
@@ -3,11 +3,20 @@ import type { DownloadJob } from '../types';
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
getDownloads: build.query<DownloadJob[], { status?: DownloadJob['status'] } | void>({
getDownloads: build.query<
DownloadJob[],
{ status?: DownloadJob['status'] } | void
>({
query: (params) => ({ url: '/downloads', params: params ?? {} }),
providesTags: ['Download'],
}),
addDownload: build.mutation<DownloadJob, { url: string; metadata?: { title?: string; artist?: string; album?: string } }>({
addDownload: build.mutation<
DownloadJob,
{
url: string;
metadata?: { title?: string; artist?: string; album?: string };
}
>({
query: (body) => ({ url: '/downloads', method: 'POST', body }),
invalidatesTags: ['Download'],
}),
@@ -23,4 +32,9 @@ export const downloadsApi = api.injectEndpoints({
overrideExisting: false,
});
export const { useGetDownloadsQuery, useAddDownloadMutation, useCancelDownloadMutation, useRetryDownloadMutation } = downloadsApi;
export const {
useGetDownloadsQuery,
useAddDownloadMutation,
useCancelDownloadMutation,
useRetryDownloadMutation,
} = downloadsApi;
+47 -9
View File
@@ -1,5 +1,11 @@
import { api } from '../index';
import type { Track, Album, Artist, PaginatedResponse, LibraryFilters } from '../types';
import type {
Track,
Album,
Artist,
PaginatedResponse,
LibraryFilters,
} from '../types';
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
@@ -7,18 +13,32 @@ export const libraryApi = api.injectEndpoints({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Track' as const, id })), 'Track']
? [
...result.items.map(({ id }) => ({ type: 'Track' as const, id })),
'Track',
]
: ['Track'],
}),
getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}),
getAlbums: build.query<PaginatedResponse<Album>, { search?: string; artistId?: string; page?: number; pageSize?: number } | void>({
getAlbums: build.query<
PaginatedResponse<Album>,
{
search?: string;
artistId?: string;
page?: number;
pageSize?: number;
} | void
>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Album' as const, id })), 'Album']
? [
...result.items.map(({ id }) => ({ type: 'Album' as const, id })),
'Album',
]
: ['Album'],
}),
getAlbum: build.query<Album, string>({
@@ -27,13 +47,25 @@ export const libraryApi = api.injectEndpoints({
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
providesTags: (_r, _e, albumId) => [{ type: 'Album', id: albumId }, 'Track'],
providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId },
'Track',
],
}),
getArtists: build.query<PaginatedResponse<Artist>, { search?: string; page?: number; pageSize?: number } | void>({
getArtists: build.query<
PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void
>({
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Artist' as const, id })), 'Artist']
? [
...result.items.map(({ id }) => ({
type: 'Artist' as const,
id,
})),
'Artist',
]
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
@@ -42,9 +74,15 @@ export const libraryApi = api.injectEndpoints({
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
providesTags: (_r, _e, artistId) => [{ type: 'Artist', id: artistId }, 'Album'],
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Album',
],
}),
searchLibrary: build.query<{ tracks: Track[]; albums: Album[]; artists: Artist[] }, string>({
searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
string
>({
query: (q) => ({ url: '/library/search', params: { q } }),
providesTags: ['Track', 'Album', 'Artist'],
}),
+4 -1
View File
@@ -7,7 +7,10 @@ export const likesApi = api.injectEndpoints({
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'DELETE' }),
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
}),
+36 -9
View File
@@ -15,25 +15,52 @@ export const playlistsApi = api.injectEndpoints({
query: (id) => `/playlists/${id}/tracks`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
createPlaylist: build.mutation<Playlist, { name: string; description?: string; isPublic?: boolean }>({
createPlaylist: build.mutation<
Playlist,
{ name: string; description?: string; isPublic?: boolean }
>({
query: (body) => ({ url: '/playlists', method: 'POST', body }),
invalidatesTags: ['Playlist'],
}),
updatePlaylist: build.mutation<Playlist, { id: string; name?: string; description?: string; isPublic?: boolean }>({
query: ({ id, ...body }) => ({ url: `/playlists/${id}`, method: 'PATCH', body }),
updatePlaylist: build.mutation<
Playlist,
{ id: string; name?: string; description?: string; isPublic?: boolean }
>({
query: ({ id, ...body }) => ({
url: `/playlists/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}),
deletePlaylist: build.mutation<void, string>({
query: (id) => ({ url: `/playlists/${id}`, method: 'DELETE' }),
invalidatesTags: ['Playlist'],
}),
addTrackToPlaylist: build.mutation<void, { playlistId: string; trackId: string }>({
query: ({ playlistId, trackId }) => ({ url: `/playlists/${playlistId}/tracks`, method: 'POST', body: { trackId } }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
addTrackToPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string }
>({
query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks`,
method: 'POST',
body: { trackId },
}),
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
],
}),
removeTrackFromPlaylist: build.mutation<void, { playlistId: string; trackId: string; position: number }>({
query: ({ playlistId, position }) => ({ url: `/playlists/${playlistId}/tracks/${position}`, method: 'DELETE' }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
removeTrackFromPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string; position: number }
>({
query: ({ playlistId, position }) => ({
url: `/playlists/${playlistId}/tracks/${position}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
],
}),
}),
overrideExisting: false,
+9 -2
View File
@@ -12,11 +12,18 @@ export const storageApi = api.injectEndpoints({
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
}),
deleteTrackFile: build.mutation<void, string>({
query: (trackId) => ({ url: `/storage/tracks/${trackId}`, method: 'DELETE' }),
query: (trackId) => ({
url: `/storage/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
}),
}),
overrideExisting: false,
});
export const { useGetStorageStatsQuery, useScanStorageMutation, useDeleteTrackFileMutation } = storageApi;
export const {
useGetStorageStatsQuery,
useScanStorageMutation,
useDeleteTrackFileMutation,
} = storageApi;
+2 -1
View File
@@ -7,7 +7,8 @@ export function getStreamUrl(trackId: string, token: string): string {
export function getCoverUrl(artUrl: string | undefined): string | undefined {
if (!artUrl) return undefined;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://')) return artUrl;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://'))
return artUrl;
const base = getApiBaseUrl();
return `${base}${artUrl}`;
}
+10 -1
View File
@@ -4,6 +4,15 @@ import { baseQueryWithReauth } from './baseQuery';
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
tagTypes: ['Track', 'Album', 'Artist', 'Playlist', 'Download', 'Like', 'User', 'Storage'],
tagTypes: [
'Track',
'Album',
'Artist',
'Playlist',
'Download',
'Like',
'User',
'Storage',
],
endpoints: () => ({}),
});
+76
View File
@@ -0,0 +1,76 @@
/*
* Procedural cover-art tile: a folder-colour gloss + initials, picked
* deterministically from a seed string. Used for dense rows (player, queue)
* and as a stand-in while real album art is absent (stub mode). Mirrors the
* design reference's ArtTile.
*/
// jewel-tone palette nodding to the Ubuntu "folder colour" idea
const TILE_PALETTE: ReadonlyArray<readonly [string, string]> = [
['#bef264', '#5a7d1e'], // lime
['#e9572b', '#7a2410'], // ember
['#6db3f2', '#1d4d78'], // blue
['#b07bf2', '#48246e'], // purple
['#e6a93c', '#7a531a'], // amber
['#4fd1c5', '#155e57'], // teal
['#f26db3', '#7a2452'], // pink
];
function hashHue(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h;
}
interface Props {
seed: string;
size?: number;
radius?: number;
label?: string;
src?: string;
}
export function ArtTile({ seed, size = 40, radius, label, src }: Props) {
const r = radius ?? Math.max(4, Math.round(size * 0.16));
if (src) {
return (
<img
src={src}
alt=""
width={size}
height={size}
className="arttile"
style={{ borderRadius: r, objectFit: 'cover' }}
/>
);
}
const [a, b] = TILE_PALETTE[hashHue(seed || 'x') % TILE_PALETTE.length];
const initials = (label ?? '')
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase();
return (
<div
className="arttile"
style={{
width: size,
height: size,
borderRadius: r,
background: `linear-gradient(150deg, ${a}, ${b})`,
}}
>
<span className="arttile-sheen" style={{ borderRadius: r }} />
<span
className="arttile-initials"
style={{ fontSize: Math.max(9, size * 0.3) }}
>
{initials}
</span>
</div>
);
}
+26 -4
View File
@@ -7,13 +7,35 @@ interface EmptyStateProps {
action?: ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
export function EmptyState({
icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 2rem', gap: '1rem', textAlign: 'center', color: 'var(--color-text-2)' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 2rem',
gap: '1rem',
textAlign: 'center',
color: 'var(--color-text-2)',
}}
>
{icon && <div style={{ fontSize: '2.5rem', opacity: 0.5 }}>{icon}</div>}
<div>
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>{title}</p>
{description && <p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>{description}</p>}
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>
{title}
</p>
{description && (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>
{description}
</p>
)}
</div>
{action}
</div>
+10 -2
View File
@@ -5,13 +5,21 @@ interface ErrorStateProps {
onRetry?: () => void;
}
export function ErrorState({ message = 'Something went wrong', onRetry }: ErrorStateProps) {
export function ErrorState({
message = 'Something went wrong',
onRetry,
}: ErrorStateProps) {
return (
<div style={{ padding: '2rem' }}>
<Callout variant="danger">
{message}
{onRetry && (
<Button variant="ghost" size="sm" onClick={onRetry} style={{ marginLeft: '1rem' }}>
<Button
variant="ghost"
size="sm"
onClick={onRetry}
style={{ marginLeft: '1rem' }}
>
Retry
</Button>
)}
+101
View File
@@ -0,0 +1,101 @@
/*
* Thin wrapper over @phosphor-icons/react so app code can reference icons by
* the kebab names used in the design reference (`<Icon name="vinyl-record" />`)
* instead of importing each component. modern-sk is intentionally icon-agnostic
* (its SearchField/MenuItem/Callout take an `icon` ReactNode), so the icon set
* is the app's concern.
*
* The rendered <svg> carries className "ph" and sizes to 1em, so the shell CSS
* controls size/colour via font-size + currentColor, exactly like the reference.
*/
import type { CSSProperties } from 'react';
import {
ArrowCircleDown,
ArrowsClockwise,
CheckCircle,
Cloud,
DotsSixVertical,
GearSix,
HardDrives,
Heart,
House,
MagnifyingGlass,
Pause,
Play,
Playlist,
Plus,
PushPin,
Queue,
Radio,
Repeat,
ShieldCheck,
Shuffle,
SignOut,
SkipBack,
SkipForward,
Sparkle,
SpeakerHigh,
SpeakerSimpleX,
ThumbsDown,
Trash,
UploadSimple,
VinylRecord,
WarningCircle,
X,
type IconProps,
} from '@phosphor-icons/react';
const ICONS = {
'vinyl-record': VinylRecord,
house: House,
'magnifying-glass': MagnifyingGlass,
'arrow-circle-down': ArrowCircleDown,
'upload-simple': UploadSimple,
'hard-drives': HardDrives,
'push-pin': PushPin,
playlist: Playlist,
plus: Plus,
'shield-check': ShieldCheck,
'gear-six': GearSix,
queue: Queue,
trash: Trash,
x: X,
radio: Radio,
sparkle: Sparkle,
'dots-six-vertical': DotsSixVertical,
shuffle: Shuffle,
'skip-back': SkipBack,
play: Play,
pause: Pause,
'skip-forward': SkipForward,
repeat: Repeat,
heart: Heart,
'thumbs-down': ThumbsDown,
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
cloud: Cloud,
'check-circle': CheckCircle,
'warning-circle': WarningCircle,
'sign-out': SignOut,
'arrows-clockwise': ArrowsClockwise,
} satisfies Record<string, React.ComponentType<IconProps>>;
export type IconName = keyof typeof ICONS;
interface Props {
name: IconName;
fill?: boolean;
style?: CSSProperties;
className?: string;
}
export function Icon({ name, fill, style, className }: Props) {
const Cmp = ICONS[name];
return (
<Cmp
weight={fill ? 'fill' : 'regular'}
className={className ? `ph ${className}` : 'ph'}
style={{ flexShrink: 0, ...style }}
/>
);
}
+14 -7
View File
@@ -5,17 +5,24 @@ import { QueuePanel } from '../player/QueuePanel';
export function AppShell() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
<div className="app-body">
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
<Outlet />
<main className="app-main">
<div className="app-screen">
<Outlet />
</div>
</main>
<QueuePanel />
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<PersistentPlayer />
</div>
<PersistentPlayer />
</div>
);
}
+123 -89
View File
@@ -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>
);
}
+119 -62
View File
@@ -1,6 +1,17 @@
import { IconButton, Slider, Tooltip } from 'modern-sk';
import { Slider } from 'modern-sk';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { pause, resume, toggleMute, setVolume, toggleNowPlaying, toggleQueue } from '../../store/slices/player';
import {
pause,
resume,
toggleMute,
setVolume,
toggleShuffle,
setRepeat,
toggleNowPlaying,
toggleQueue,
} from '../../store/slices/player';
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
@@ -13,94 +24,140 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) {
return (
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 1.5rem', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
Nothing playing
</div>
);
return <div className="player empty">Nothing playing</div>;
}
const artUrl = currentEntry?.albumArtUrl ? getCoverUrl(currentEntry.albumArtUrl) : undefined;
const progressPercent = player.duration > 0 ? (player.position / player.duration) * 100 : 0;
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
return (
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '0 1rem', gap: '1rem', background: 'var(--color-surface-1)' }}>
{/* track info */}
<div className="player">
{/* now-playing identity */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0, cursor: 'pointer' }}
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
style={{ cursor: 'pointer' }}
>
{artUrl ? (
<img src={artUrl} alt="" width={40} height={40} style={{ borderRadius: 4, objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{ width: 40, height: 40, borderRadius: 4, background: 'var(--color-surface-3)', flexShrink: 0 }} />
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{currentEntry?.title ?? '—'}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentEntry?.artistName ?? ''}
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt">
<div className="t">{currentEntry?.title ?? '—'}</div>
<div className="a">{currentEntry?.artistName ?? ''}</div>
<div
className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
</div>
</div>
</div>
{/* controls + scrubber */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', minWidth: '20rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<IconButton variant="ghost" size="sm" onClick={playPrev} aria-label="Previous"></IconButton>
<IconButton
variant="primary"
onClick={() => player.isPlaying ? dispatch(pause()) : dispatch(resume())}
aria-label={player.isPlaying ? 'Pause' : 'Play'}
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
>
{player.isPlaying ? '⏸' : '▶'}
</IconButton>
<IconButton variant="ghost" size="sm" onClick={playNext} aria-label="Next"></IconButton>
<Icon name="shuffle" />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
>
<Icon name="skip-back" fill />
</button>
<button
type="button"
className="pl-play"
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? 'Pause' : 'Play'}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
>
<Icon name="skip-forward" fill />
</button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={`Repeat: ${player.repeat}`}
>
<Icon name="repeat" />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem', textAlign: 'right' }}>
<div className="pl-seek">
<span className="pl-time">
{formatDuration(player.position * 1000)}
</span>
<Slider
className="pl-seek-slider"
min={0}
max={player.duration || 1}
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
style={{ flex: 1 }}
aria-label="Seek"
/>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem' }}>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
</span>
</div>
</div>
{/* volume + queue */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
<Tooltip content={player.muted ? 'Unmute' : 'Mute'}>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleMute())} aria-label="Toggle mute">
{player.muted ? '🔇' : '🔊'}
</IconButton>
</Tooltip>
<Slider
min={0}
max={1}
step={0.01}
value={[player.muted ? 0 : player.volume]}
onValueChange={([v]) => dispatch(setVolume(v))}
style={{ width: '6rem' }}
/>
<Tooltip content="Queue">
<IconButton variant={player.isQueueOpen ? 'primary' : 'ghost'} size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Toggle queue">
</IconButton>
</Tooltip>
</div>
{/* progress bar at bottom */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'var(--color-surface-3)' }}>
<div style={{ width: `${progressPercent}%`, height: '100%', background: 'var(--color-accent)', transition: 'width 0.5s linear' }} />
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
<div className="pl-vol">
<Slider
className="pl-vol-slider"
min={0}
max={1}
step={0.01}
value={[player.muted ? 0 : player.volume]}
onValueChange={([v]) => dispatch(setVolume(v))}
aria-label="Volume"
/>
</div>
<button
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title="Play queue"
>
<Icon name="queue" />
</button>
</div>
</div>
);
+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>
);
}
+26 -5
View File
@@ -5,18 +5,39 @@ interface Props {
availability: TrackAvailability;
}
const CONFIG: Record<TrackAvailability, { label: string; variant: 'lime' | 'ember' | 'neutral' | 'outline'; tooltip: string }> = {
server: { label: 'On server', variant: 'lime', tooltip: 'File available on server' },
downloading: { label: 'Downloading', variant: 'neutral', tooltip: 'Currently downloading' },
const CONFIG: Record<
TrackAvailability,
{
label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline';
tooltip: string;
}
> = {
server: {
label: 'On server',
variant: 'lime',
tooltip: 'File available on server',
},
downloading: {
label: 'Downloading',
variant: 'neutral',
tooltip: 'Currently downloading',
},
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
missing: { label: 'Missing', variant: 'outline', tooltip: 'File not found on server' },
missing: {
label: 'Missing',
variant: 'outline',
tooltip: 'File not found on server',
},
};
export function AvailabilityBadge({ availability }: Props) {
const cfg = CONFIG[availability];
return (
<Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot>{cfg.label}</Badge>
<Badge variant={cfg.variant} dot>
{cfg.label}
</Badge>
</Tooltip>
);
}
+55 -10
View File
@@ -1,4 +1,11 @@
import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, IconButton } from 'modern-sk';
import {
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
IconButton,
} from 'modern-sk';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player';
@@ -12,7 +19,13 @@ interface Props {
onDownload?: (track: Track) => void;
}
export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDelete, onDownload }: Props) {
export function TrackContextMenu({
track,
onAddToPlaylist,
onEditMetadata,
onDelete,
onDownload,
}: Props) {
const dispatch = useAppDispatch();
const entry = {
@@ -27,18 +40,50 @@ export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDel
return (
<Menu>
<MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options"></IconButton>
<IconButton variant="ghost" size="sm" aria-label="Track options">
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>Play now</MenuItem>
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>Play next</MenuItem>
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>Add to queue</MenuItem>
<MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
Play now
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
Play next
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
Add to queue
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && <MenuItem onSelect={() => onAddToPlaylist(track)}>Add to playlist</MenuItem>}
{onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist
</MenuItem>
)}
<MenuSeparator />
{onEditMetadata && <MenuItem onSelect={() => onEditMetadata(track)}>Edit metadata</MenuItem>}
{onDownload && <MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>}
{onDelete && <MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>}
{onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata
</MenuItem>
)}
{onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
)}
{onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
)}
</MenuContent>
</Menu>
);
+67 -10
View File
@@ -16,7 +16,14 @@ interface Props {
onDelete?: (track: Track) => void;
}
export function TrackRow({ track, index, showAlbum = false, onAddToPlaylist, onEditMetadata, onDelete }: Props) {
export function TrackRow({
track,
index,
showAlbum = false,
onAddToPlaylist,
onEditMetadata,
onDelete,
}: Props) {
const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
@@ -27,27 +34,77 @@ export function TrackRow({ track, index, showAlbum = false, onAddToPlaylist, onE
<Row
selected={isActive}
onDoubleClick={() => dispatch(play(track.id))}
style={{ display: 'grid', gridTemplateColumns: '2rem 2.5rem 1fr auto auto', gap: '0.75rem', alignItems: 'center', padding: '0.375rem 0.75rem', cursor: 'default' }}
style={{
display: 'grid',
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
gap: '0.75rem',
alignItems: 'center',
padding: '0.375rem 0.75rem',
cursor: 'default',
}}
>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', textAlign: 'right' }}>
{isActive && isPlaying ? '▶' : (index !== undefined ? index + 1 : '')}
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textAlign: 'right',
}}
>
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
</span>
{artUrl ? (
<img src={artUrl} alt="" width={36} height={36} style={{ borderRadius: 4, objectFit: 'cover' }} />
<img
src={artUrl}
alt=""
width={36}
height={36}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<div style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--color-surface-3)' }} />
<div
style={{
width: 36,
height: 36,
borderRadius: 4,
background: 'var(--color-surface-3)',
}}
/>
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: isActive ? 600 : 400, color: isActive ? 'var(--color-accent)' : 'var(--color-text-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div
style={{
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--color-accent)' : 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.title}
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{track.artistName}{showAlbum && ` · ${track.albumTitle}`}
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.artistName}
{showAlbum && ` · ${track.albumTitle}`}
</div>
</div>
<AvailabilityBadge availability={track.availability} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)', minWidth: '3rem', textAlign: 'right' }}>
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
minWidth: '3rem',
textAlign: 'right',
}}
>
{formatDuration(track.durationMs)}
</span>
<TrackContextMenu
+2 -1
View File
@@ -1 +1,2 @@
export const DEFAULT_API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
export const DEFAULT_API_BASE_URL =
import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
+186
View File
@@ -0,0 +1,186 @@
/*
* Multi-instance backend registry + per-backend scoped storage.
*
* The web UI is backend-agnostic: it can connect to any server speaking the
* `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is
* therefore bound to the *instance it came from* — never global. Each saved
* backend gets a stable `id` derived from its base URL, and every persisted key
* lives under the `mcma:<id>:` namespace so switching backends can never mix
* one server's session/cache into another's.
*
* Layout in localStorage:
* mcma:instances -> Instance[] (the saved-backends registry)
* mcma:activeInstance -> <id> (which one is current)
* mcma:<id>:auth -> persisted auth slice (per-backend)
* mcma:<id>:<key> -> any other scoped value
*/
export interface Instance {
id: string;
baseUrl: string;
name: string;
lastUsedAt: number;
}
const REGISTRY_KEY = 'mcma:instances';
const ACTIVE_KEY = 'mcma:activeInstance';
// pre-multi-instance keys, migrated once on first load
const LEGACY_URL_KEY = 'mcma_api_base_url';
const LEGACY_AUTH_KEY = 'mcma_auth';
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
/** Stable, readable id from a base URL — also serves as the storage namespace. */
export function instanceIdFromUrl(url: string): string {
const stripped = normalizeUrl(url)
.toLowerCase()
.replace(/^https?:\/\//, '');
const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'default';
}
function defaultName(baseUrl: string): string {
try {
const u = new URL(
/^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`,
);
return u.host || baseUrl;
} catch {
return baseUrl;
}
}
function readRegistry(): Instance[] {
try {
const raw = localStorage.getItem(REGISTRY_KEY);
return raw ? (JSON.parse(raw) as Instance[]) : [];
} catch {
return [];
}
}
function writeRegistry(list: Instance[]): void {
try {
localStorage.setItem(REGISTRY_KEY, JSON.stringify(list));
} catch {
/* storage unavailable */
}
}
/** Saved backends, most-recently-used first. */
export function listInstances(): Instance[] {
return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt);
}
/** Add (or refresh) a backend in the registry and return its record. */
export function upsertInstance(url: string, name?: string): Instance {
const baseUrl = normalizeUrl(url);
const id = instanceIdFromUrl(baseUrl);
const list = readRegistry();
const existing = list.find((i) => i.id === id);
const inst: Instance = {
id,
baseUrl,
name: name ?? existing?.name ?? defaultName(baseUrl),
lastUsedAt: Date.now(),
};
writeRegistry(
existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst],
);
return inst;
}
/** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id));
clearScope(id);
if (getActiveInstanceId() === id) {
const next = listInstances()[0];
if (next) setActiveInstanceId(next.id);
else localStorage.removeItem(ACTIVE_KEY);
}
}
export function getActiveInstanceId(): string | null {
return localStorage.getItem(ACTIVE_KEY);
}
export function setActiveInstanceId(id: string): void {
localStorage.setItem(ACTIVE_KEY, id);
}
export function getActiveInstance(): Instance | null {
const id = getActiveInstanceId();
if (!id) return null;
return readRegistry().find((i) => i.id === id) ?? null;
}
/** Build the namespaced localStorage key for a value scoped to a backend. */
export function scopedKey(
key: string,
instanceId: string | null = getActiveInstanceId(),
): string {
return `mcma:${instanceId ?? 'none'}:${key}`;
}
/**
* Per-backend key/value store. Reads/writes are no-ops when no instance is
* active, so callers never have to special-case the unconnected state.
*/
export const instanceStorage = {
get(key: string): string | null {
const id = getActiveInstanceId();
return id ? localStorage.getItem(scopedKey(key, id)) : null;
},
set(key: string, value: string): void {
const id = getActiveInstanceId();
if (!id) return;
try {
localStorage.setItem(scopedKey(key, id), value);
} catch {
/* storage unavailable */
}
},
remove(key: string): void {
const id = getActiveInstanceId();
if (id) localStorage.removeItem(scopedKey(key, id));
},
};
function clearScope(id: string): void {
const prefix = `mcma:${id}:`;
const toRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(prefix)) toRemove.push(k);
}
toRemove.forEach((k) => localStorage.removeItem(k));
}
/** One-time migration of the old single-backend keys into the namespaced model. */
function migrateLegacy(): void {
try {
const legacyUrl = localStorage.getItem(LEGACY_URL_KEY);
if (!legacyUrl || readRegistry().length > 0) return;
const inst = upsertInstance(legacyUrl);
const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY);
if (legacyAuth)
localStorage.setItem(scopedKey('auth', inst.id), legacyAuth);
setActiveInstanceId(inst.id);
localStorage.removeItem(LEGACY_URL_KEY);
localStorage.removeItem(LEGACY_AUTH_KEY);
} catch {
/* best-effort */
}
}
// Fold any pre-multi-instance keys into the namespaced model on first load.
// We deliberately do NOT seed an instance from the env default: before the
// user connects there is no active instance, getApiBaseUrl() falls back to the
// env default, and the connect flow registers the real backend.
if (typeof localStorage !== 'undefined') {
migrateLegacy();
}
+15 -8
View File
@@ -1,15 +1,22 @@
import { DEFAULT_API_BASE_URL } from './env';
import {
getActiveInstance,
upsertInstance,
setActiveInstanceId,
} from './instances';
const STORAGE_KEY = 'mcma_api_base_url';
/**
* Base-URL resolution. The active backend (chosen via ConnectPage) wins; if
* none is active we fall back to the env default. See `instances.ts` for the
* per-backend registry that backs this — every persisted value is namespaced
* to the instance it came from.
*/
export function getApiBaseUrl(): string {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_API_BASE_URL;
return getActiveInstance()?.baseUrl ?? DEFAULT_API_BASE_URL;
}
/** Register a backend and make it the active one (used by the connect flow). */
export function setApiBaseUrl(url: string): void {
localStorage.setItem(STORAGE_KEY, url);
}
export function clearApiBaseUrl(): void {
localStorage.removeItem(STORAGE_KEY);
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function AdminPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Admin"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+128 -29
View File
@@ -1,6 +1,9 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useGetAlbumQuery, useGetAlbumTracksQuery } from '../../api/endpoints/library';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
@@ -19,11 +22,20 @@ export function AlbumDetailPage() {
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (albumQuery.isError) {
return <ErrorState message="Failed to load album" onRetry={() => albumQuery.refetch()} />;
return (
<ErrorState
message="Failed to load album"
onRetry={() => albumQuery.refetch()}
/>
);
}
const album = albumQuery.data;
@@ -32,52 +44,139 @@ export function AlbumDetailPage() {
const handlePlayAll = () => {
if (!tracks.length || !album) return;
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'album',
sourceId: album.id,
sourceName: album.title,
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'album',
sourceId: album.id,
sourceName: album.title,
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back"></IconButton>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-end', flex: 1 }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
>
</IconButton>
<div
style={{
display: 'flex',
gap: '1.5rem',
alignItems: 'flex-end',
flex: 1,
}}
>
{artUrl ? (
<img src={artUrl} alt={album?.title} width={96} height={96} style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />
<img
src={artUrl}
alt={album?.title}
width={96}
height={96}
style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }}
/>
) : (
<div style={{ width: 96, height: 96, borderRadius: 8, background: 'var(--color-surface-3)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2.5rem' }}>💿</div>
<div
style={{
width: 96,
height: 96,
borderRadius: 8,
background: 'var(--color-surface-3)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
}}
>
💿
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Album</p>
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{album?.title}</h1>
<p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Album
</p>
<h1
style={{
margin: '0.25rem 0',
fontSize: '1.5rem',
fontWeight: 700,
}}
>
{album?.title}
</h1>
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album && ` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}> Play</Button>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
<EmptyState icon="♫" title="No tracks" description="This album has no tracks." />
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />
))}
+186 -22
View File
@@ -1,24 +1,48 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout } from 'modern-sk';
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl, getApiBaseUrl } from '../../config/runtime-config';
import { setApiBaseUrl } from '../../config/runtime-config';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
} from '../../config/instances';
import type { User } from '../../api/types';
export function ConnectPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [apiUrl, setApiUrl] = useState(getApiBaseUrl);
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// STUB: no backend yet. Fake a session so the rest of the app is reachable.
// Replace with the real useLoginMutation() flow once the backend exists.
// Switching to a saved backend reloads the app so every slice re-initialises
// from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const forget = (id: string) => {
removeInstance(id);
setRev((r) => r + 1);
};
// STUB: no backend yet. Register the instance, then fake a session so the rest
// of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl);
setApiBaseUrl(apiUrl); // upsert + activate this backend
const fakeUser: User = {
id: 'dev-user',
@@ -26,21 +50,158 @@ export function ConnectPage() {
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(setTokens({ accessToken: 'dev-token', refreshToken: 'dev-refresh', expiresIn: 3600 }));
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)', padding: '2rem' }}>
<div style={{ width: '100%', maxWidth: '24rem' }}>
<h1 style={{ textAlign: 'center', marginBottom: '2rem', color: 'var(--color-accent)', fontSize: '1.75rem' }}> MCMA</h1>
<div
key={rev}
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
}}
>
<div
style={{
width: '100%',
maxWidth: '26rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1
style={{
textAlign: 'center',
color: 'var(--color-accent)',
fontSize: '1.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
<Icon name="vinyl-record" fill /> MCMA
</h1>
{instances.length > 0 && (
<Card>
<div
style={{
padding: '1.25rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
</span>
{instances.map((inst) => (
<div
key={inst.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
>
<Icon name="trash" />
</button>
</div>
))}
</div>
</Card>
)}
<Card>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1.5rem' }}>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Server URL
</label>
<label style={labelStyle}>Server URL</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
@@ -49,9 +210,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Username
</label>
<label style={labelStyle}>Username</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -61,9 +220,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Password
</label>
<label style={labelStyle}>Password</label>
<TextField
type="password"
value={password}
@@ -73,8 +230,15 @@ export function ConnectPage() {
required
/>
</div>
<Callout variant="warning">Stub mode backend not wired. Connect signs in with a fake admin session.</Callout>
<Button type="submit" variant="primary" style={{ marginTop: '0.5rem' }}>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
>
Connect
</Button>
</form>
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function DownloadsManagerPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Downloads"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Downloads">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+258 -46
View File
@@ -1,11 +1,47 @@
import { useState } from 'react';
import {
Button, IconButton, TextField, TextArea, SearchField, Select,
Switch, Checkbox, RadioGroup, RadioItem, Control, SegmentedControl,
Slider, Stepper, Tabs, TabsList, TabsContent, Progress, Badge, Chip,
Card, List, Row, Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator,
Tooltip, Spinner, Callout, Table, THead, TBody, Tr, Th, Td,
Dialog, DialogClose, AlertDialog, Window, useTheme,
Button,
IconButton,
TextField,
TextArea,
SearchField,
Select,
Switch,
Checkbox,
RadioGroup,
RadioItem,
Control,
SegmentedControl,
Slider,
Stepper,
Tabs,
TabsList,
TabsContent,
Progress,
Badge,
Chip,
Card,
List,
Row,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Tooltip,
Spinner,
Callout,
Table,
THead,
TBody,
Tr,
Th,
Td,
Dialog,
DialogClose,
AlertDialog,
Window,
useTheme,
} from 'modern-sk';
const sectionStyle: React.CSSProperties = {
@@ -29,7 +65,13 @@ const labelStyle: React.CSSProperties = {
color: 'var(--color-text-3)',
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
<span style={labelStyle}>{title}</span>
@@ -52,16 +94,42 @@ export function HomePage() {
return (
<div style={{ overflow: 'auto', height: '100%' }}>
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem', maxWidth: '64rem', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: '64rem',
margin: '0 auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}> MCMA Component Kitchen Sink</h1>
<p style={{ margin: '0.25rem 0 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>
MCMA Component Kitchen Sink
</h1>
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
modern-sk reference. Project base ready for development.
</p>
</div>
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'}`}>
<Button variant="ghost" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
<Button
variant="ghost"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</Button>
</Tooltip>
@@ -73,22 +141,45 @@ export function HomePage() {
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" disabled>Disabled</Button>
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
<div style={rowWrap}>
<IconButton variant="primary" aria-label="Play"></IconButton>
<IconButton variant="ghost" aria-label="Next"></IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop"></IconButton>
<Stepper onDecrement={() => setCount((c) => c - 1)} onIncrement={() => setCount((c) => c + 1)} />
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>count: {count}</span>
<IconButton variant="primary" aria-label="Play">
</IconButton>
<IconButton variant="ghost" aria-label="Next">
</IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop">
</IconButton>
<Stepper
onDecrement={() => setCount((c) => c - 1)}
onIncrement={() => setCount((c) => c + 1)}
/>
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}
>
count: {count}
</span>
</div>
</Section>
<Section title="Inputs">
<div style={rowWrap}>
<TextField placeholder="Text field" style={{ width: '14rem' }} />
<SearchField icon="⌕" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search…" style={{ width: '14rem' }} />
<SearchField
icon="⌕"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
style={{ width: '14rem' }}
/>
<Select
placeholder="Pick genre"
aria-label="Genre"
@@ -106,10 +197,20 @@ export function HomePage() {
<Section title="Toggles & selection">
<div style={rowWrap}>
<Control control={<Switch checked={switchOn} onCheckedChange={setSwitchOn} />}>Switch</Control>
<Control
control={
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
}
>
Switch
</Control>
<Control control={<Checkbox defaultChecked />}>Checkbox</Control>
</div>
<RadioGroup value={radio} onValueChange={setRadio} style={{ display: 'flex', gap: '1rem' }}>
<RadioGroup
value={radio}
onValueChange={setRadio}
style={{ display: 'flex', gap: '1rem' }}
>
<Control control={<RadioItem value="a" />}>Option A</Control>
<Control control={<RadioItem value="b" />}>Option B</Control>
<Control control={<RadioItem value="c" />}>Option C</Control>
@@ -126,15 +227,28 @@ export function HomePage() {
</Section>
<Section title="Sliders & progress">
<Slider min={0} max={100} step={1} value={vol} onValueChange={setVol} marks notches="bottom" />
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>value: {vol[0]}</span>
<Slider
min={0}
max={100}
step={1}
value={vol}
onValueChange={setVol}
notches="bottom"
/>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>
value: {vol[0]}
</span>
<Progress value={vol[0]} />
</Section>
<Section title="Badges, chips, spinner">
<div style={rowWrap}>
<Badge variant="lime" dot>On server</Badge>
<Badge variant="ember" dot>Error</Badge>
<Badge variant="lime" dot>
On server
</Badge>
<Badge variant="ember" dot>
Error
</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Spinner size="sm" />
@@ -142,45 +256,118 @@ export function HomePage() {
</div>
<div style={rowWrap}>
{chips.map((c) => (
<Chip key={c} onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}>{c}</Chip>
<Chip
key={c}
onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}
>
{c}
</Chip>
))}
{chips.length === 0 && <span style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}>all removed</span>}
{chips.length === 0 && (
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}
>
all removed
</span>
)}
</div>
</Section>
<Section title="Callouts">
<Callout variant="info">Info backend address resolves from runtime env relative /api/v1.</Callout>
<Callout variant="success">Success typecheck and lint pass clean.</Callout>
<Callout variant="warning">Warning most feature screens are still stubs.</Callout>
<Callout variant="danger">Danger destructive actions use AlertDialog.</Callout>
<Callout variant="info">
Info backend address resolves from runtime env relative
/api/v1.
</Callout>
<Callout variant="success">
Success typecheck and lint pass clean.
</Callout>
<Callout variant="warning">
Warning most feature screens are still stubs.
</Callout>
<Callout variant="danger">
Danger destructive actions use AlertDialog.
</Callout>
</Section>
<Section title="Tabs">
<Tabs value={tab} onValueChange={setTab}>
<TabsList items={[{ value: 'one', label: 'First' }, { value: 'two', label: 'Second' }, { value: 'three', label: 'Third' }]} />
<TabsContent value="one" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>First panel</TabsContent>
<TabsContent value="two" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>Second panel</TabsContent>
<TabsContent value="three" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>Third panel</TabsContent>
<TabsList
items={[
{ value: 'one', label: 'First' },
{ value: 'two', label: 'Second' },
{ value: 'three', label: 'Third' },
]}
/>
<TabsContent
value="one"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
First panel
</TabsContent>
<TabsContent
value="two"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Second panel
</TabsContent>
<TabsContent
value="three"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Third panel
</TabsContent>
</Tabs>
</Section>
<Section title="List & rows">
<List>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track one Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>Track two Artist (selected)</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track three Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>
Track two Artist (selected)
</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>
Track three Artist
</Row>
</List>
</Section>
<Section title="Table">
<Table>
<THead>
<Tr><Th>Title</Th><Th>Artist</Th><Th>Duration</Th></Tr>
<Tr>
<Th>Title</Th>
<Th>Artist</Th>
<Th>Duration</Th>
</Tr>
</THead>
<TBody>
<Tr><Td>Intro</Td><Td>Aphex</Td><Td>2:14</Td></Tr>
<Tr selected><Td>Windowlicker</Td><Td>Aphex</Td><Td>6:07</Td></Tr>
<Tr><Td>Avril 14th</Td><Td>Aphex</Td><Td>2:01</Td></Tr>
<Tr>
<Td>Intro</Td>
<Td>Aphex</Td>
<Td>2:14</Td>
</Tr>
<Tr selected>
<Td>Windowlicker</Td>
<Td>Aphex</Td>
<Td>6:07</Td>
</Tr>
<Tr>
<Td>Avril 14th</Td>
<Td>Aphex</Td>
<Td>2:01</Td>
</Tr>
</TBody>
</Table>
</Section>
@@ -203,9 +390,21 @@ export function HomePage() {
trigger={<Button variant="primary">Open dialog</Button>}
title="Dialog title"
description="Composed from modern-sk primitives."
footer={<DialogClose asChild><Button variant="primary">Done</Button></DialogClose>}
footer={
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
}
>
<p style={{ color: 'var(--color-text-2)', fontSize: '0.875rem', margin: 0 }}>Dialog body content.</p>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Dialog body content.
</p>
</Dialog>
<AlertDialog
@@ -220,8 +419,21 @@ export function HomePage() {
</Section>
<Section title="Window">
<Window title="Now Playing" badge={<Badge variant="lime" dot>live</Badge>}>
<p style={{ color: 'var(--color-text-2)', fontSize: '0.875rem', margin: 0 }}>
<Window
title="Now Playing"
badge={
<Badge variant="lime" dot>
live
</Badge>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Window chrome for grouped content.
</p>
</Window>
+225 -54
View File
@@ -1,7 +1,18 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Tabs, TabsList, TabsContent, SearchField, ScrollArea, Card } from 'modern-sk';
import { useGetTracksQuery, useGetAlbumsQuery, useGetArtistsQuery } from '../../api/endpoints/library';
import {
Tabs,
TabsList,
TabsContent,
SearchField,
ScrollArea,
Card,
} from 'modern-sk';
import {
useGetTracksQuery,
useGetAlbumsQuery,
useGetArtistsQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
@@ -23,24 +34,37 @@ export function LibraryPage() {
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
const handlePlayAll = (tracks: Track[]) => {
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>Library</h2>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
@@ -51,50 +75,116 @@ export function LibraryPage() {
</div>
</div>
<Tabs value={tab} onValueChange={setTab} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '0 1.5rem', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<TabsList items={[{ value: 'tracks', label: 'Tracks' }, { value: 'albums', label: 'Albums' }, { value: 'artists', label: 'Artists' }]} />
<Tabs
value={tab}
onValueChange={setTab}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div
style={{
padding: '0 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<TabsList
items={[
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
]}
/>
</div>
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
{tracksQuery.isError && <ErrorState onRetry={() => tracksQuery.refetch()} />}
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState icon="♫" title="No tracks" description="Your library is empty. Start by downloading some music." />
{tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} />
)}
{tracksQuery.data && tracksQuery.data.items.length > 0 && (() => {
const data = tracksQuery.data!;
return (
<div>
<div style={{ padding: '0.5rem 0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'center', borderBottom: '1px solid var(--color-border)' }}>
<button
onClick={() => handlePlayAll(data.items)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-accent)', fontSize: '0.875rem', fontWeight: 500 }}
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="Your library is empty. Start by downloading some music."
/>
)}
{tracksQuery.data &&
tracksQuery.data.items.length > 0 &&
(() => {
const data = tracksQuery.data!;
return (
<div>
<div
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
borderBottom: '1px solid var(--color-border)',
}}
>
Play all ({data.total})
</button>
<button
onClick={() => handlePlayAll(data.items)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-accent)',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Play all ({data.total})
</button>
</div>
{data.items.map((track, i) => (
<TrackRow
key={track.id}
track={track}
index={i}
showAlbum
/>
))}
</div>
{data.items.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} showAlbum />
))}
</div>
);
})()}
);
})()}
</ScrollArea>
</TabsContent>
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
{albumsQuery.isError && <ErrorState onRetry={() => albumsQuery.refetch()} />}
{albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} />
)}
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState icon="💿" title="No albums" description="No albums in library." />
<EmptyState
icon="💿"
title="No albums"
description="No albums in library."
/>
)}
{albumsQuery.data && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '1rem', padding: '1.25rem 1.5rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))',
gap: '1rem',
padding: '1.25rem 1.5rem',
}}
>
{albumsQuery.data.items.map((album) => (
<AlbumCard key={album.id} album={album} onClick={() => void navigate(`/library/albums/${album.id}`)} />
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/library/albums/${album.id}`)}
/>
))}
</div>
)}
@@ -104,9 +194,15 @@ export function LibraryPage() {
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
{artistsQuery.isError && <ErrorState onRetry={() => artistsQuery.refetch()} />}
{artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} />
)}
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState icon="🎤" title="No artists" description="No artists in library." />
<EmptyState
icon="🎤"
title="No artists"
description="No artists in library."
/>
)}
{artistsQuery.data && (
<div style={{ padding: '0.5rem 0' }}>
@@ -127,17 +223,67 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
return (
<Card
onClick={onClick}
style={{ cursor: 'pointer', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
style={{
cursor: 'pointer',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{artUrl ? (
<img src={artUrl} alt={album.title} style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 6 }} />
<img
src={artUrl}
alt={album.title}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: 6,
}}
/>
) : (
<div style={{ width: '100%', aspectRatio: '1', background: 'var(--color-surface-3)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2rem' }}>💿</div>
<div
style={{
width: '100%',
aspectRatio: '1',
background: 'var(--color-surface-3)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
}}
>
💿
</div>
)}
<div>
<div style={{ fontWeight: 600, fontSize: '0.8125rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.title}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.artistName}</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}</div>
<div
style={{
fontWeight: 600,
fontSize: '0.8125rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.title}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.artistName}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
</div>
</div>
</Card>
);
@@ -145,11 +291,36 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
function ArtistRow({ artist }: { artist: Artist }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 1.5rem' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'var(--color-surface-3)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: '1.25rem' }}>🎤</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 1.5rem',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--color-surface-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '1.25rem',
}}
>
🎤
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>{artist.name}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{artist.albumCount} albums · {artist.trackCount} tracks</div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{artist.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{artist.albumCount} albums · {artist.trackCount} tracks
</div>
</div>
</div>
);
@@ -1,6 +1,9 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useGetPlaylistQuery, useGetPlaylistTracksQuery } from '../../api/endpoints/playlists';
import {
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
} from '../../api/endpoints/playlists';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
@@ -14,15 +17,28 @@ export function PlaylistDetailPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const playlistQuery = useGetPlaylistQuery(playlistId ?? '', { skip: !playlistId });
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', { skip: !playlistId });
const playlistQuery = useGetPlaylistQuery(playlistId ?? '', {
skip: !playlistId,
});
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', {
skip: !playlistId,
});
if (playlistQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (playlistQuery.isError) {
return <ErrorState message="Failed to load playlist" onRetry={() => playlistQuery.refetch()} />;
return (
<ErrorState
message="Failed to load playlist"
onRetry={() => playlistQuery.refetch()}
/>
);
}
const playlist = playlistQuery.data;
@@ -30,44 +46,115 @@ export function PlaylistDetailPage() {
const handlePlayAll = () => {
if (!tracks.length || !playlist) return;
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'playlist',
sourceId: playlist.id,
sourceName: playlist.name,
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'playlist',
sourceId: playlist.id,
sourceName: playlist.name,
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back"></IconButton>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
>
</IconButton>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Playlist</p>
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{playlist?.name}</h1>
{playlist?.description && <p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>{playlist.description}</p>}
<p style={{ margin: '0.25rem 0 0', color: 'var(--color-text-3)', fontSize: '0.8125rem' }}>
{playlist && `${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Playlist
</p>
<h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
>
{playlist?.name}
</h1>
{playlist?.description && (
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{playlist.description}
</p>
)}
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-3)',
fontSize: '0.8125rem',
}}
>
{playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
</p>
</div>
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}> Play</Button>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
</Button>
</div>
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
<EmptyState icon="♫" title="Empty playlist" description="This playlist has no tracks yet." />
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
<EmptyState
icon="♫"
title="Empty playlist"
description="This playlist has no tracks yet."
/>
)}
{tracks.map((track, i) => (
<TrackRow key={`${track.id}-${i}`} track={track} index={i} showAlbum />
<TrackRow
key={`${track.id}-${i}`}
track={track}
index={i}
showAlbum
/>
))}
</ScrollArea>
</div>
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function SearchDownloadPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Search & Download"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Search & Download">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function SettingsPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Settings"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Settings">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function StoragePage() {
return <div style={{ padding: '1.5rem' }}><Window title="Storage"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Storage">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+2 -1
View File
@@ -2,4 +2,5 @@ import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from '../store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(selector: (state: RootState) => T) => useSelector(selector);
export const useAppSelector = <T>(selector: (state: RootState) => T) =>
useSelector(selector);
+27 -12
View File
@@ -1,7 +1,10 @@
import { useEffect, useRef, useCallback } from 'react';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
pause, resume, setPosition, setDuration,
pause,
resume,
setPosition,
setDuration,
setVolume as setVolumeAction,
} from '../store/slices/player';
import { nextTrack, prevTrack } from '../store/slices/queue';
@@ -87,7 +90,9 @@ export function useAudioPlayer() {
useEffect(() => {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
navigator.mediaSession.playbackState = player.isPlaying
? 'playing'
: 'paused';
}, [player.isPlaying]);
useEffect(() => {
@@ -120,18 +125,28 @@ export function useAudioPlayer() {
}
}, [queue.currentIndex, queue.entries, player.currentTrackId, dispatch]);
const seek = useCallback((seconds: number) => {
const audio = getAudio();
audio.currentTime = seconds;
dispatch(setPosition(seconds));
}, [dispatch]);
const seek = useCallback(
(seconds: number) => {
const audio = getAudio();
audio.currentTime = seconds;
dispatch(setPosition(seconds));
},
[dispatch],
);
const setPlayerVolume = useCallback((vol: number) => {
dispatch(setVolumeAction(vol));
}, [dispatch]);
const setPlayerVolume = useCallback(
(vol: number) => {
dispatch(setVolumeAction(vol));
},
[dispatch],
);
const playNext = useCallback(() => { dispatch(nextTrack()); }, [dispatch]);
const playPrev = useCallback(() => { dispatch(prevTrack()); }, [dispatch]);
const playNext = useCallback(() => {
dispatch(nextTrack());
}, [dispatch]);
const playPrev = useCallback(() => {
dispatch(prevTrack());
}, [dispatch]);
return { seek, setVolume: setPlayerVolume, playNext, playPrev };
}
+10 -3
View File
@@ -13,7 +13,9 @@ export function useConnectionStatus() {
if (cancelled) return;
setStatus('connecting');
try {
const res = await fetch(`${getApiBaseUrl()}/health`, { signal: AbortSignal.timeout(5000) });
const res = await fetch(`${getApiBaseUrl()}/health`, {
signal: AbortSignal.timeout(5000),
});
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
} catch {
if (!cancelled) setStatus('disconnected');
@@ -21,8 +23,13 @@ export function useConnectionStatus() {
};
void check();
const interval = setInterval(() => { void check(); }, 30_000);
return () => { cancelled = true; clearInterval(interval); };
const interval = setInterval(() => {
void check();
}, 30_000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
return status;
+15 -2
View File
@@ -1,9 +1,22 @@
import { useAppSelector } from './useAppDispatch';
type Permission = 'download' | 'upload' | 'admin' | 'manage_users' | 'edit_metadata' | 'delete_tracks';
type Permission =
| 'download'
| 'upload'
| 'admin'
| 'manage_users'
| 'edit_metadata'
| 'delete_tracks';
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: ['download', 'upload', 'admin', 'manage_users', 'edit_metadata', 'delete_tracks'],
admin: [
'download',
'upload',
'admin',
'manage_users',
'edit_metadata',
'delete_tracks',
],
user: ['download', 'upload'],
};
+1
View File
@@ -1,6 +1,7 @@
import 'modern-sk/styles.css';
import 'modern-sk/fonts.css';
import './styles/global.css';
import './styles/shell.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
+4 -2
View File
@@ -3,14 +3,16 @@ export function formatDuration(ms: number): string {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
if (h > 0)
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
+67 -12
View File
@@ -9,13 +9,35 @@ import { PlaylistDetailPage } from '../features/playlist-detail/PlaylistDetailPa
import { lazy, Suspense } from 'react';
import { LoadingSkeleton } from '../components/common/LoadingSkeleton';
const SearchDownloadPage = lazy(() => import('../features/search-download/SearchDownloadPage').then((m) => ({ default: m.SearchDownloadPage })));
const DownloadsManagerPage = lazy(() => import('../features/downloads-manager/DownloadsManagerPage').then((m) => ({ default: m.DownloadsManagerPage })));
const StoragePage = lazy(() => import('../features/storage/StoragePage').then((m) => ({ default: m.StoragePage })));
const AdminPage = lazy(() => import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })));
const SettingsPage = lazy(() => import('../features/settings/SettingsPage').then((m) => ({ default: m.SettingsPage })));
const SearchDownloadPage = lazy(() =>
import('../features/search-download/SearchDownloadPage').then((m) => ({
default: m.SearchDownloadPage,
})),
);
const DownloadsManagerPage = lazy(() =>
import('../features/downloads-manager/DownloadsManagerPage').then((m) => ({
default: m.DownloadsManagerPage,
})),
);
const StoragePage = lazy(() =>
import('../features/storage/StoragePage').then((m) => ({
default: m.StoragePage,
})),
);
const AdminPage = lazy(() =>
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
);
const SettingsPage = lazy(() =>
import('../features/settings/SettingsPage').then((m) => ({
default: m.SettingsPage,
})),
);
const Fallback = () => <div style={{ padding: '2rem' }}><LoadingSkeleton /></div>;
const Fallback = () => (
<div style={{ padding: '2rem' }}>
<LoadingSkeleton />
</div>
);
export function AppRoutes() {
return (
@@ -31,16 +53,49 @@ export function AppRoutes() {
<Route index element={<HomePage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="/library/albums/:albumId" element={<AlbumDetailPage />} />
<Route path="/library/playlists/:playlistId" element={<PlaylistDetailPage />} />
<Route path="/search" element={<Suspense fallback={<Fallback />}><SearchDownloadPage /></Suspense>} />
<Route path="/downloads" element={<Suspense fallback={<Fallback />}><DownloadsManagerPage /></Suspense>} />
<Route path="/storage" element={<Suspense fallback={<Fallback />}><StoragePage /></Suspense>} />
<Route path="/settings" element={<Suspense fallback={<Fallback />}><SettingsPage /></Suspense>} />
<Route
path="/library/playlists/:playlistId"
element={<PlaylistDetailPage />}
/>
<Route
path="/search"
element={
<Suspense fallback={<Fallback />}>
<SearchDownloadPage />
</Suspense>
}
/>
<Route
path="/downloads"
element={
<Suspense fallback={<Fallback />}>
<DownloadsManagerPage />
</Suspense>
}
/>
<Route
path="/storage"
element={
<Suspense fallback={<Fallback />}>
<StoragePage />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<Fallback />}>
<SettingsPage />
</Suspense>
}
/>
<Route
path="/admin/*"
element={
<ProtectedRoute requireAdmin>
<Suspense fallback={<Fallback />}><AdminPage /></Suspense>
<Suspense fallback={<Fallback />}>
<AdminPage />
</Suspense>
</ProtectedRoute>
}
/>
+18 -7
View File
@@ -1,5 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { User } from '../../api/types';
import { instanceStorage } from '../../config/instances';
interface AuthState {
accessToken: string | null;
@@ -8,9 +9,11 @@ interface AuthState {
user: User | null;
}
// Auth is bound to the active backend: tokens persist under that instance's
// namespace, so connecting to a different server never reuses another's session.
const loadPersistedAuth = (): Partial<AuthState> => {
try {
const raw = localStorage.getItem('mcma_auth');
const raw = instanceStorage.get('auth');
return raw ? (JSON.parse(raw) as Partial<AuthState>) : {};
} catch {
return {};
@@ -30,7 +33,14 @@ export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setTokens(state, action: PayloadAction<{ accessToken: string; refreshToken: string; expiresIn: number }>) {
setTokens(
state,
action: PayloadAction<{
accessToken: string;
refreshToken: string;
expiresIn: number;
}>,
) {
state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.expiresAt = Date.now() + action.payload.expiresIn * 1000;
@@ -45,20 +55,21 @@ export const authSlice = createSlice({
state.refreshToken = null;
state.expiresAt = null;
state.user = null;
localStorage.removeItem('mcma_auth');
instanceStorage.remove('auth');
},
},
});
function persistAuth(state: AuthState): void {
try {
localStorage.setItem('mcma_auth', JSON.stringify({
instanceStorage.set(
'auth',
JSON.stringify({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
user: state.user,
}));
} catch { /* storage unavailable */ }
}),
);
}
export const { setTokens, setUser, logout } = authSlice.actions;
+51 -18
View File
@@ -20,12 +20,14 @@ const initialState: PlayerState = {
isPlaying: false,
position: 0,
duration: 0,
volume: 0.8,
volume: 0.78,
muted: false,
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
isQueueOpen: false,
// STUB: open by default so the queue drawer look is visible before a backend
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
isQueueOpen: true,
};
export const playerSlice = createSlice({
@@ -37,25 +39,56 @@ export const playerSlice = createSlice({
state.isPlaying = true;
state.position = 0;
},
pause(state) { state.isPlaying = false; },
resume(state) { state.isPlaying = true; },
stop(state) { state.isPlaying = false; state.currentTrackId = null; state.position = 0; },
setPosition(state, action: PayloadAction<number>) { state.position = action.payload; },
setDuration(state, action: PayloadAction<number>) { state.duration = action.payload; },
setVolume(state, action: PayloadAction<number>) { state.volume = action.payload; },
toggleMute(state) { state.muted = !state.muted; },
setRepeat(state, action: PayloadAction<RepeatMode>) { state.repeat = action.payload; },
toggleShuffle(state) { state.shuffle = !state.shuffle; },
toggleNowPlaying(state) { state.isNowPlayingOpen = !state.isNowPlayingOpen; },
toggleQueue(state) { state.isQueueOpen = !state.isQueueOpen; },
pause(state) {
state.isPlaying = false;
},
resume(state) {
state.isPlaying = true;
},
stop(state) {
state.isPlaying = false;
state.currentTrackId = null;
state.position = 0;
},
setPosition(state, action: PayloadAction<number>) {
state.position = action.payload;
},
setDuration(state, action: PayloadAction<number>) {
state.duration = action.payload;
},
setVolume(state, action: PayloadAction<number>) {
state.volume = action.payload;
},
toggleMute(state) {
state.muted = !state.muted;
},
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen;
},
},
});
export const {
play, pause, resume, stop,
setPosition, setDuration,
setVolume, toggleMute,
setRepeat, toggleShuffle,
toggleNowPlaying, toggleQueue,
play,
pause,
resume,
stop,
setPosition,
setDuration,
setVolume,
toggleMute,
setRepeat,
toggleShuffle,
toggleNowPlaying,
toggleQueue,
} = playerSlice.actions;
export default playerSlice.reducer;
+69 -10
View File
@@ -1,6 +1,12 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type QueueSource = 'manual' | 'album' | 'playlist' | 'artist' | 'search';
type QueueSource =
| 'manual'
| 'album'
| 'playlist'
| 'artist'
| 'search'
| 'radio';
interface QueueEntry {
trackId: string;
@@ -19,19 +25,63 @@ interface QueueState {
sourceName: string | null;
}
// STUB demo queue — purely client-side display data so the player bar and
// queue drawer render with content before the backend exists. Delete this
// block (reset entries/currentIndex/source to the empty values) once real
// playback wires tracks into the queue.
const DEMO_QUEUE: QueueEntry[] = [
{
trackId: 'd1',
title: 'Quiet Storage',
artistName: 'Cyan Atlas',
albumTitle: 'Night Index',
durationMs: 312_000,
},
{
trackId: 'd2',
title: 'Magnetic North',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 243_000,
},
{
trackId: 'd3',
title: 'Ambergris',
artistName: 'Møller',
albumTitle: 'Warm Static',
durationMs: 201_000,
},
{
trackId: 'd4',
title: 'Slow Carrier',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 301_000,
},
];
const initialState: QueueState = {
entries: [],
currentIndex: -1,
source: 'manual',
entries: DEMO_QUEUE,
currentIndex: 0,
source: 'radio',
sourceId: null,
sourceName: null,
sourceName: 'My radio',
};
export const queueSlice = createSlice({
name: 'queue',
initialState,
reducers: {
setQueue(state, action: PayloadAction<{ entries: QueueEntry[]; startIndex?: number; source: QueueSource; sourceId?: string; sourceName?: string }>) {
setQueue(
state,
action: PayloadAction<{
entries: QueueEntry[];
startIndex?: number;
source: QueueSource;
sourceId?: string;
sourceName?: string;
}>,
) {
state.entries = action.payload.entries;
state.currentIndex = action.payload.startIndex ?? 0;
state.source = action.payload.source;
@@ -53,8 +103,10 @@ export const queueSlice = createSlice({
const [entry] = state.entries.splice(from, 1);
state.entries.splice(to, 0, entry);
if (state.currentIndex === from) state.currentIndex = to;
else if (from < state.currentIndex && to >= state.currentIndex) state.currentIndex--;
else if (from > state.currentIndex && to <= state.currentIndex) state.currentIndex++;
else if (from < state.currentIndex && to >= state.currentIndex)
state.currentIndex--;
else if (from > state.currentIndex && to <= state.currentIndex)
state.currentIndex++;
},
goToIndex(state, action: PayloadAction<number>) {
state.currentIndex = action.payload;
@@ -73,7 +125,14 @@ export const queueSlice = createSlice({
});
export const {
setQueue, addToQueue, addNextInQueue, removeFromQueue,
moveInQueue, goToIndex, nextTrack, prevTrack, clearQueue,
setQueue,
addToQueue,
addNextInQueue,
removeFromQueue,
moveInQueue,
goToIndex,
nextTrack,
prevTrack,
clearQueue,
} = queueSlice.actions;
export default queueSlice.reducer;
+22 -6
View File
@@ -16,13 +16,29 @@ export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar(state) { state.sidebarCollapsed = !state.sidebarCollapsed; },
setSidebarCollapsed(state, action: PayloadAction<boolean>) { state.sidebarCollapsed = action.payload; },
openModal(state, action: PayloadAction<string>) { state.activeModal = action.payload; },
closeModal(state) { state.activeModal = null; },
setActiveContextMenu(state, action: PayloadAction<string | null>) { state.activeTrackContextMenuId = action.payload; },
toggleSidebar(state) {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
setSidebarCollapsed(state, action: PayloadAction<boolean>) {
state.sidebarCollapsed = action.payload;
},
openModal(state, action: PayloadAction<string>) {
state.activeModal = action.payload;
},
closeModal(state) {
state.activeModal = null;
},
setActiveContextMenu(state, action: PayloadAction<string | null>) {
state.activeTrackContextMenuId = action.payload;
},
},
});
export const { toggleSidebar, setSidebarCollapsed, openModal, closeModal, setActiveContextMenu } = uiSlice.actions;
export const {
toggleSidebar,
setSidebarCollapsed,
openModal,
closeModal,
setActiveContextMenu,
} = uiSlice.actions;
export default uiSlice.reducer;
+709
View File
@@ -0,0 +1,709 @@
/* ============================================================
App shell look — sidebar, queue drawer, player bar.
Transferred from the ModernSK Music design reference and layered on
top of modern-sk's tokens + component CSS. Only layout + product
classes live here; every colour/shadow/radius flows from modern-sk
tokens so the whole shell restyles from one place (and stays in sync
with a future Flutter client sharing the same tokens).
Icons are @phosphor-icons/react <svg> elements carrying className "ph";
they size to 1em, so font-size here controls them like the reference.
============================================================ */
.ph {
display: inline-block;
line-height: 0;
}
/* uppercase section label (not shipped by modern-sk) */
.msk-label {
font-family: var(--font-sans);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: var(--track-caps);
color: var(--fg-3);
}
/* ---- shell layout ---- */
.app-body {
flex: 1;
display: flex;
min-height: 0;
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.app-screen {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* ============================================================
SIDEBAR
============================================================ */
.sidebar {
width: 236px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
border-right: 1px solid var(--hair);
}
.sb-scroll {
flex: 1;
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
overflow-y: auto;
padding: 14px 12px 6px;
}
.sb-brand {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 8px 14px;
font-family: var(--font-display, var(--font-sans));
font-size: 18px;
font-weight: 700;
letter-spacing: var(--track-snug);
color: var(--fg-1);
}
.sb-brand .ph {
color: var(--lime);
font-size: 20px;
}
.sb-sec {
margin-bottom: 18px;
}
.sb-sec .msk-label {
padding: 0 8px 7px;
display: block;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 8px 10px;
border-radius: var(--r-md);
font-size: 14px;
font-weight: 500;
color: var(--fg-2);
cursor: pointer;
border: 1px solid transparent;
background: transparent;
text-align: left;
text-decoration: none;
transition:
background var(--dur-quick) var(--ease-out),
color var(--dur-quick) var(--ease-out);
}
.nav-item .ph {
font-size: 18px;
color: var(--fg-3);
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--fg-1);
}
.nav-item:hover .ph {
color: var(--fg-2);
}
.nav-item.active {
color: var(--fg-1);
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.16),
rgba(190, 242, 100, 0.07)
);
border-color: rgba(190, 242, 100, 0.25);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
.nav-item.active .ph {
color: var(--lime);
}
.nav-item .nav-badge {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
color: var(--fg-on-ember, #fff);
background: var(--ember);
padding: 1px 6px;
border-radius: var(--r-pill);
}
.nav-item .nav-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-3);
}
.pl-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 10px;
border-radius: var(--r-md);
font-size: 13px;
color: var(--fg-2);
cursor: pointer;
border: 1px solid transparent;
background: transparent;
text-align: left;
text-decoration: none;
}
.pl-item .ph {
font-size: 15px;
color: var(--fg-3);
}
.pl-item .pl-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.pl-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--fg-1);
}
.pl-item.active {
color: var(--fg-1);
background: rgba(255, 255, 255, 0.05);
border-color: var(--hair);
}
.pl-item .sync-led {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
margin-left: auto;
}
.sync-synced {
background: var(--lime);
box-shadow: 0 0 5px var(--lime);
}
.sync-pending {
background: var(--warning);
}
.sync-conflict {
background: var(--ember);
box-shadow: 0 0 5px var(--ember);
}
.sb-foot {
flex-shrink: 0;
border-top: 1px solid var(--hair);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 6px;
border-radius: var(--r-md);
cursor: pointer;
background: transparent;
border: none;
width: 100%;
text-align: left;
}
.user-chip:hover {
background: rgba(255, 255, 255, 0.04);
}
.user-av {
width: 30px;
height: 30px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--lime-ink);
background: linear-gradient(180deg, var(--lime-bright), var(--lime-deep));
}
.user-meta {
line-height: 1.2;
min-width: 0;
flex: 1;
}
.user-meta .nm {
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-meta .rl {
font-size: 11px;
color: var(--fg-3);
}
.user-chip .uc-action {
margin-left: auto;
color: var(--fg-3);
font-size: 16px;
display: inline-flex;
}
.user-chip .uc-action:hover {
color: var(--fg-1);
}
/* connection status pill (used in sidebar foot) */
.conn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px 5px 9px;
border-radius: var(--r-pill);
font-size: 12px;
font-weight: 600;
color: var(--fg-2);
cursor: default;
background: var(--steel-900);
border: 1px solid var(--hair);
box-shadow: var(--shadow-inset-well);
}
.conn .led {
width: 7px;
height: 7px;
border-radius: 50%;
box-shadow: 0 0 6px currentColor;
}
.conn.online .led {
background: var(--lime);
color: var(--lime);
}
.conn.offline .led {
background: var(--fg-3);
color: var(--fg-3);
box-shadow: none;
}
.conn.syncing .led {
background: var(--info);
color: var(--info);
animation: conn-pulse 1.2s var(--ease-out) infinite;
}
.conn.error .led {
background: var(--ember);
color: var(--ember);
}
@keyframes conn-pulse {
50% {
opacity: 0.35;
}
}
/* ---- shared icon button ---- */
.iconbtn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--r-md);
border: 1px solid var(--hair-strong);
background: var(--grad-key);
box-shadow: var(--shadow-raised);
color: var(--fg-2);
cursor: pointer;
font-size: 16px;
transition:
background var(--dur-quick),
color var(--dur-quick),
box-shadow var(--dur-quick),
transform var(--dur-quick);
}
.iconbtn:hover {
background: var(--grad-key-hover);
color: var(--fg-1);
}
.iconbtn:active {
box-shadow: var(--shadow-pressed, var(--shadow-inset-well));
transform: translateY(1px);
}
.iconbtn.sm {
width: 26px;
height: 26px;
font-size: 14px;
}
.iconbtn.on {
color: var(--lime);
border-color: rgba(190, 242, 100, 0.3);
}
/* ---- art tile ---- */
.arttile {
position: relative;
flex-shrink: 0;
overflow: hidden;
display: block;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(0, 0, 0, 0.25);
}
.arttile-sheen {
position: absolute;
inset: 0;
background: linear-gradient(
155deg,
rgba(255, 255, 255, 0.32),
transparent 45%
);
pointer-events: none;
}
.arttile-initials {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display, var(--font-sans));
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
letter-spacing: 0.02em;
}
/* ---- availability dot ---- */
.avail {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
.avail .avail-pct {
font-family: var(--font-mono);
font-size: 10px;
}
/* ============================================================
PLAYER BAR
============================================================ */
.player {
flex-shrink: 0;
height: 84px;
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(380px, 2fr) minmax(
220px,
1fr
);
align-items: center;
gap: 16px;
padding: 0 18px;
border-top: 1px solid var(--hair-strong);
background: linear-gradient(180deg, var(--steel-800), var(--steel-900));
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset;
}
.player.empty {
justify-content: center;
color: var(--fg-3);
font-size: 13px;
display: flex;
}
.pl-now {
display: flex;
align-items: center;
gap: 13px;
min-width: 0;
}
.pl-now-tt {
min-width: 0;
}
.pl-now .t {
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-now .a {
font-size: 12px;
color: var(--fg-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-srcbadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
}
.pl-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
}
.pl-transport {
display: flex;
align-items: center;
gap: 14px;
}
.pl-tbtn {
background: none;
border: none;
color: var(--fg-2);
cursor: pointer;
font-size: 19px;
display: flex;
align-items: center;
transition: color var(--dur-quick);
}
.pl-tbtn:hover {
color: var(--fg-1);
}
.pl-tbtn.on {
color: var(--lime);
}
.pl-play {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--lime-ink);
background: var(--grad-primary);
border: 1px solid var(--lime-deep);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.3) inset,
var(--glow-lime),
0 2px 4px rgba(0, 0, 0, 0.35);
cursor: pointer;
transition:
filter var(--dur-quick),
transform var(--dur-quick);
}
.pl-play:hover {
filter: brightness(1.05);
}
.pl-play:active {
transform: translateY(1px);
}
.pl-seek {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 560px;
}
.pl-time {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-3);
width: 36px;
text-align: center;
flex-shrink: 0;
}
.pl-seek-slider {
flex: 1;
}
.pl-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.pl-vol {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-3);
}
.pl-vol-slider {
width: 84px;
}
/* ============================================================
QUEUE DRAWER (A11)
============================================================ */
/* Outer wrapper animates width 336px <-> 0 so the queue drawer slides in/out
smoothly; the fixed-width inner keeps content from reflowing while it moves. */
.qd {
width: 336px;
flex-shrink: 0;
overflow: hidden;
border-left: 1px solid var(--hair);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.2));
transition:
width 0.24s var(--ease-out),
border-left-color 0.24s var(--ease-out);
}
.qd.closed {
width: 0;
border-left-color: transparent;
}
.qd-inner {
width: 336px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.qd-head {
flex-shrink: 0;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--hair);
}
.qd-head .row {
display: flex;
align-items: center;
gap: 8px;
}
.qd-head h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--fg-1);
}
.qd-src {
font-size: 12px;
color: var(--fg-3);
margin-top: 4px;
display: flex;
align-items: center;
gap: 7px;
}
.qd-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 12px 18px;
}
.qd-now {
display: flex;
gap: 11px;
align-items: center;
padding: 10px;
border-radius: var(--r-md);
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.13),
rgba(190, 242, 100, 0.05)
);
border: 1px solid rgba(190, 242, 100, 0.2);
margin-bottom: 14px;
}
.qd-now .qt {
min-width: 0;
flex: 1;
}
.qd-now .qt .t {
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-now .qt .r {
font-size: 11px;
color: var(--fg-3);
}
.qrow {
display: flex;
gap: 11px;
align-items: center;
padding: 8px;
border-radius: var(--r-md);
cursor: grab;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.qrow:hover {
background: rgba(255, 255, 255, 0.04);
}
.qrow .grip {
color: var(--fg-3);
font-size: 15px;
cursor: grab;
display: inline-flex;
}
.qrow .qt {
min-width: 0;
flex: 1;
}
.qrow .qt .t {
font-size: 13px;
font-weight: 500;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qrow .qt .r {
font-size: 11px;
color: var(--fg-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
}
.qrow .qt .r .ph {
color: var(--lime);
font-size: 11px;
}
.qd-radio {
margin-bottom: 14px;
padding: 12px;
border-radius: var(--r-md);
background: var(--steel-900);
border: 1px solid var(--hair);
box-shadow: var(--shadow-inset-well);
}
.qd-radio .row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.qd-radio .row .ph {
color: var(--lime);
}
.expl {
display: flex;
align-items: center;
gap: 10px;
}
.expl .lab {
font-size: 11px;
color: var(--fg-3);
white-space: nowrap;
}
.expl-slider {
flex: 1;
}
.qd-empty {
text-align: center;
padding: 40px 20px;
color: var(--fg-3);
font-size: 13px;
}
.qd-loadmore {
text-align: center;
padding: 14px 0;
color: var(--fg-3);
font-size: 12px;
}