feat: auth & admin
This commit is contained in:
+41
-20
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: () => ({}),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user