Project started 🥂

This commit is contained in:
2026-06-02 01:13:22 +03:00
commit 612d0f0125
146 changed files with 15242 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
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';
const rawBaseQuery = () =>
fetchBaseQuery({
baseUrl: getApiBaseUrl(),
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
});
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 (refreshToken) {
const refreshResult = await rawBaseQuery()({
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
}, 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());
}
} else {
api.dispatch(logout());
}
}
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { api } from '../index';
import type { User } from '../types';
export const adminApi = api.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<User[], void>({
query: () => '/admin/users',
providesTags: ['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 }),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
invalidatesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation, useDeleteUserMutation } = adminApi;
+23
View File
@@ -0,0 +1,23 @@
import { api } from '../index';
import type { LoginRequest, LoginResponse } from '../types';
export const authApi = api.injectEndpoints({
endpoints: (build) => ({
login: build.mutation<LoginResponse, LoginRequest>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
}),
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
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>({
query: () => '/auth/me',
providesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
+26
View File
@@ -0,0 +1,26 @@
import { api } from '../index';
import type { DownloadJob } from '../types';
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
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 } }>({
query: (body) => ({ url: '/downloads', method: 'POST', body }),
invalidatesTags: ['Download'],
}),
cancelDownload: build.mutation<void, string>({
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
invalidatesTags: ['Download'],
}),
retryDownload: build.mutation<DownloadJob, string>({
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
invalidatesTags: ['Download'],
}),
}),
overrideExisting: false,
});
export const { useGetDownloadsQuery, useAddDownloadMutation, useCancelDownloadMutation, useRetryDownloadMutation } = downloadsApi;
+65
View File
@@ -0,0 +1,65 @@
import { api } from '../index';
import type { Track, Album, Artist, PaginatedResponse, LibraryFilters } from '../types';
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
providesTags: (result) =>
result
? [...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>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Album' as const, id })), 'Album']
: ['Album'],
}),
getAlbum: build.query<Album, string>({
query: (id) => `/library/albums/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
providesTags: (_r, _e, albumId) => [{ type: 'Album', id: albumId }, 'Track'],
}),
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']
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
query: (id) => `/library/artists/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
providesTags: (_r, _e, artistId) => [{ type: 'Artist', id: artistId }, 'Album'],
}),
searchLibrary: build.query<{ tracks: Track[]; albums: Album[]; artists: Artist[] }, string>({
query: (q) => ({ url: '/library/search', params: { q } }),
providesTags: ['Track', 'Album', 'Artist'],
}),
}),
overrideExisting: false,
});
export const {
useGetTracksQuery,
useGetTrackQuery,
useGetAlbumsQuery,
useGetAlbumQuery,
useGetAlbumTracksQuery,
useGetArtistsQuery,
useGetArtistQuery,
useGetArtistAlbumsQuery,
useSearchLibraryQuery,
} = libraryApi;
+17
View File
@@ -0,0 +1,17 @@
import { api } from '../index';
export const likesApi = api.injectEndpoints({
endpoints: (build) => ({
likeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'DELETE' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
}),
overrideExisting: false,
});
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi;
+51
View File
@@ -0,0 +1,51 @@
import { api } from '../index';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists',
providesTags: ['Playlist'],
}),
getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}),
getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
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 }),
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 }],
}),
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,
});
export const {
useGetPlaylistsQuery,
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
useCreatePlaylistMutation,
useUpdatePlaylistMutation,
useDeletePlaylistMutation,
useAddTrackToPlaylistMutation,
useRemoveTrackFromPlaylistMutation,
} = playlistsApi;
+22
View File
@@ -0,0 +1,22 @@
import { api } from '../index';
import type { StorageStats } from '../types';
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats',
providesTags: ['Storage'],
}),
scanStorage: build.mutation<{ jobId: string }, void>({
query: () => ({ url: '/storage/scan', method: 'POST' }),
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
}),
deleteTrackFile: build.mutation<void, string>({
query: (trackId) => ({ url: `/storage/tracks/${trackId}`, method: 'DELETE' }),
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
}),
}),
overrideExisting: false,
});
export const { useGetStorageStatsQuery, useScanStorageMutation, useDeleteTrackFileMutation } = storageApi;
+13
View File
@@ -0,0 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config';
export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl();
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`;
}
export function getCoverUrl(artUrl: string | undefined): string | undefined {
if (!artUrl) return undefined;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://')) return artUrl;
const base = getApiBaseUrl();
return `${base}${artUrl}`;
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from '../index';
import type { Track } from '../types';
export const uploadApi = api.injectEndpoints({
endpoints: (build) => ({
uploadTrack: build.mutation<Track, FormData>({
query: (formData) => ({
url: '/upload',
method: 'POST',
body: formData,
formData: true,
}),
invalidatesTags: ['Track', 'Album', 'Artist', 'Storage'],
}),
}),
overrideExisting: false,
});
export const { useUploadTrackMutation } = uploadApi;
+9
View File
@@ -0,0 +1,9 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './baseQuery';
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
tagTypes: ['Track', 'Album', 'Artist', 'Playlist', 'Download', 'Like', 'User', 'Storage'],
endpoints: () => ({}),
});
+132
View File
@@ -0,0 +1,132 @@
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
export interface Track {
id: string;
title: string;
artistId: string;
artistName: string;
albumId: string;
albumTitle: string;
albumArtUrl?: string;
durationMs: number;
trackNumber?: number;
discNumber?: number;
year?: number;
genre?: string;
availability: TrackAvailability;
fileSize?: number;
format?: string;
bitrate?: number;
liked: boolean;
}
export interface Album {
id: string;
title: string;
artistId: string;
artistName: string;
artUrl?: string;
year?: number;
trackCount: number;
totalDurationMs: number;
genre?: string;
}
export interface Artist {
id: string;
name: string;
artUrl?: string;
albumCount: number;
trackCount: number;
}
export interface Playlist {
id: string;
name: string;
description?: string;
ownerId: string;
trackCount: number;
totalDurationMs: number;
artUrl?: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
export interface PlaylistTrack extends Track {
position: number;
addedAt: string;
}
export interface DownloadJob {
id: string;
url: string;
title?: string;
artist?: string;
album?: string;
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
progress: number;
errorMessage?: string;
trackId?: string;
createdAt: string;
updatedAt: string;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
trackCount: number;
albumCount: number;
artistCount: number;
}
export interface User {
id: string;
username: string;
email?: string;
role: 'admin' | 'user';
createdAt: string;
lastActiveAt?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
user: User;
tokens: AuthTokens;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface LibraryFilters {
search?: string;
genre?: string;
artistId?: string;
albumId?: string;
liked?: boolean;
page?: number;
pageSize?: number;
sortBy?: 'title' | 'artist' | 'album' | 'year' | 'dateAdded';
sortOrder?: 'asc' | 'desc';
}
export interface ApiError {
status: number;
message: string;
code?: string;
}
@@ -0,0 +1,30 @@
import { Badge, Tooltip } from 'modern-sk';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { getApiBaseUrl } from '../../config/runtime-config';
const STATUS_LABELS = {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Disconnected',
error: 'Connection error',
} as const;
const STATUS_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
export function ConnectionStatus() {
const status = useConnectionStatus();
const baseUrl = getApiBaseUrl();
return (
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}>
<Badge variant={STATUS_VARIANTS[status]} dot>
{STATUS_LABELS[status]}
</Badge>
</Tooltip>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { type ReactNode } from 'react';
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
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)' }}>
{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>}
</div>
{action}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Callout, Button } from 'modern-sk';
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
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' }}>
Retry
</Button>
)}
</Callout>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
interface SkeletonProps {
rows?: number;
height?: number;
}
export function LoadingSkeleton({ rows = 5, height = 56 }: SkeletonProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
style={{
height,
borderRadius: 6,
background: 'var(--color-surface-2)',
animation: 'pulse 1.5s ease-in-out infinite',
animationDelay: `${i * 0.07}s`,
}}
/>
))}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Outlet } from 'react-router';
import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer';
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' }}>
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
<Outlet />
</main>
<QueuePanel />
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<PersistentPlayer />
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Badge } from 'modern-sk';
import { NavLink, useNavigate } from 'react-router';
import { ConnectionStatus } from '../common/ConnectionStatus';
import { useAppSelector, useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions';
import { logout } from '../../store/slices/auth';
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;
const ADMIN_ITEMS = [
{ to: '/admin', label: 'Admin', icon: '🔑' },
] as const;
export function Sidebar() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin } = usePermissions();
const collapsed = useAppSelector((s) => s.ui.sidebarCollapsed);
const handleLogout = () => {
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>
<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',
})}
>
<span style={{ flexShrink: 0, fontSize: '1rem' }}>{icon}</span>
{!collapsed && label}
</NavLink>
))}
{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>
</div>
)}
</div>
</nav>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { IconButton, Slider, Tooltip } from 'modern-sk';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { pause, resume, toggleMute, setVolume, toggleNowPlaying, toggleQueue } from '../../store/slices/player';
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
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>
);
}
const artUrl = currentEntry?.albumArtUrl ? getCoverUrl(currentEntry.albumArtUrl) : undefined;
const progressPercent = player.duration > 0 ? (player.position / player.duration) * 100 : 0;
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
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0, cursor: 'pointer' }}
onClick={() => dispatch(toggleNowPlaying())}
>
{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 ?? ''}
</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'}
>
{player.isPlaying ? '⏸' : '▶'}
</IconButton>
<IconButton variant="ghost" size="sm" onClick={playNext} aria-label="Next"></IconButton>
</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' }}>
{formatDuration(player.position * 1000)}
</span>
<Slider
min={0}
max={player.duration || 1}
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
style={{ flex: 1 }}
/>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem' }}>
{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>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { ScrollArea, IconButton, Badge } from 'modern-sk';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
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;
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>
</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>
);
}
@@ -0,0 +1,22 @@
import { Badge, Tooltip } from 'modern-sk';
import type { TrackAvailability } from '../../api/types';
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' },
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
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>
</Tooltip>
);
}
+45
View File
@@ -0,0 +1,45 @@
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';
import type { Track } from '../../api/types';
interface Props {
track: Track;
onAddToPlaylist?: (track: Track) => void;
onEditMetadata?: (track: Track) => void;
onDelete?: (track: Track) => void;
onDownload?: (track: Track) => void;
}
export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDelete, onDownload }: Props) {
const dispatch = useAppDispatch();
const entry = {
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
};
return (
<Menu>
<MenuTrigger asChild>
<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>
<MenuSeparator />
{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>}
</MenuContent>
</Menu>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Row } from 'modern-sk';
import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge';
import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { play } from '../../store/slices/player';
import type { Track } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming';
interface Props {
track: Track;
index?: number;
showAlbum?: boolean;
onAddToPlaylist?: (track: Track) => void;
onEditMetadata?: (track: Track) => void;
onDelete?: (track: Track) => void;
}
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);
const isActive = currentTrackId === track.id;
const artUrl = getCoverUrl(track.albumArtUrl);
return (
<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' }}
>
<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' }} />
) : (
<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' }}>
{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>
</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' }}>
{formatDuration(track.durationMs)}
</span>
<TrackContextMenu
track={track}
onAddToPlaylist={onAddToPlaylist}
onEditMetadata={onEditMetadata}
onDelete={onDelete}
/>
</div>
</Row>
);
}
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
+15
View File
@@ -0,0 +1,15 @@
import { DEFAULT_API_BASE_URL } from './env';
const STORAGE_KEY = 'mcma_api_base_url';
export function getApiBaseUrl(): string {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_API_BASE_URL;
}
export function setApiBaseUrl(url: string): void {
localStorage.setItem(STORAGE_KEY, url);
}
export function clearApiBaseUrl(): void {
localStorage.removeItem(STORAGE_KEY);
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="@rsbuild/core/types" />
interface ImportMetaEnv {
readonly PUBLIC_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+4
View File
@@ -0,0 +1,4 @@
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>;
}
@@ -0,0 +1,87 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
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';
import { EmptyState } from '../../components/common/EmptyState';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
}
if (albumQuery.isError) {
return <ErrorState message="Failed to load album" onRetry={() => albumQuery.refetch()} />;
}
const album = albumQuery.data;
const tracks = tracksQuery.data ?? [];
const artUrl = getCoverUrl(album?.artUrl);
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,
}));
};
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 }}>
{artUrl ? (
<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>
<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)}`}
</p>
</div>
</div>
<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." />
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />
))}
</ScrollArea>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout } from 'modern-sk';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl, getApiBaseUrl } from '../../config/runtime-config';
import type { User } from '../../api/types';
export function ConnectPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [apiUrl, setApiUrl] = useState(getApiBaseUrl);
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.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl);
const fakeUser: User = {
id: 'dev-user',
username: username || 'dev',
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(setTokens({ accessToken: 'dev-token', refreshToken: 'dev-refresh', expiresIn: 3600 }));
dispatch(setUser(fakeUser));
void navigate('/');
};
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>
<Card>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1.5rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Server URL
</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://your-server.example.com/api/v1"
required
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Username
</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="username"
autoComplete="username"
required
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Password
</label>
<TextField
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete="current-password"
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' }}>
Connect
</Button>
</form>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,4 @@
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>;
}
+232
View File
@@ -0,0 +1,232 @@
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,
} from 'modern-sk';
const sectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
};
const rowWrap: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--color-text-3)',
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
<span style={labelStyle}>{title}</span>
{children}
</Card>
);
}
export function HomePage() {
const { theme, setTheme } = useTheme();
const [search, setSearch] = useState('');
const [select, setSelect] = useState<string | undefined>();
const [seg, setSeg] = useState('list');
const [tab, setTab] = useState('one');
const [vol, setVol] = useState([60]);
const [count, setCount] = useState(3);
const [chips, setChips] = useState(['rock', 'jazz', 'ambient']);
const [switchOn, setSwitchOn] = useState(true);
const [radio, setRadio] = useState('a');
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>
<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')}>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</Button>
</Tooltip>
</div>
<Section title="Buttons">
<div style={rowWrap}>
<Button variant="key">Key</Button>
<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>
</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>
</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' }} />
<Select
placeholder="Pick genre"
aria-label="Genre"
value={select}
onValueChange={setSelect}
items={[
{ value: 'rock', label: 'Rock' },
{ value: 'jazz', label: 'Jazz' },
{ value: 'ambient', label: 'Ambient' },
]}
/>
</div>
<TextArea placeholder="Text area / description…" rows={3} />
</Section>
<Section title="Toggles & selection">
<div style={rowWrap}>
<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' }}>
<Control control={<RadioItem value="a" />}>Option A</Control>
<Control control={<RadioItem value="b" />}>Option B</Control>
<Control control={<RadioItem value="c" />}>Option C</Control>
</RadioGroup>
<SegmentedControl
value={seg}
onValueChange={setSeg}
items={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
{ value: 'compact', label: 'Compact' },
]}
/>
</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>
<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="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Spinner size="sm" />
<Spinner size="lg" />
</div>
<div style={rowWrap}>
{chips.map((c) => (
<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>}
</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>
</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>
</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>
</List>
</Section>
<Section title="Table">
<Table>
<THead>
<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>
</TBody>
</Table>
</Section>
<Section title="Menu, Dialog, AlertDialog">
<div style={rowWrap}>
<Menu>
<MenuTrigger asChild>
<Button variant="ghost">Open menu </Button>
</MenuTrigger>
<MenuContent>
<MenuItem>Play</MenuItem>
<MenuItem shortcut="⌘N">Add to queue</MenuItem>
<MenuSeparator />
<MenuItem>Edit metadata</MenuItem>
</MenuContent>
</Menu>
<Dialog
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>}
>
<p style={{ color: 'var(--color-text-2)', fontSize: '0.875rem', margin: 0 }}>Dialog body content.</p>
</Dialog>
<AlertDialog
trigger={<Button variant="ember">Delete</Button>}
title="Delete track?"
description="This permanently removes the file from the server."
actionLabel="Delete"
destructive
onAction={() => undefined}
/>
</div>
</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 chrome for grouped content.
</p>
</Window>
</Section>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
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 { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setQueue } from '../../store/slices/queue';
import type { Track, Album, Artist } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format';
export function LibraryPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
const [search, setSearch] = useState('');
const tracksQuery = useGetTracksQuery(search ? { search } : undefined);
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined);
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',
}));
};
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={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search library…"
icon="⌕"
/>
</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' }]} />
</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.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 }}
>
Play all ({data.total})
</button>
</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.data && albumsQuery.data.items.length === 0 && (
<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' }}>
{albumsQuery.data.items.map((album) => (
<AlbumCard key={album.id} album={album} onClick={() => void navigate(`/library/albums/${album.id}`)} />
))}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
{artistsQuery.isError && <ErrorState onRetry={() => artistsQuery.refetch()} />}
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState icon="🎤" title="No artists" description="No artists in library." />
)}
{artistsQuery.data && (
<div style={{ padding: '0.5rem 0' }}>
{artistsQuery.data.items.map((artist) => (
<ArtistRow key={artist.id} artist={artist} />
))}
</div>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const artUrl = getCoverUrl(album.artUrl);
return (
<Card
onClick={onClick}
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 }} />
) : (
<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>
</Card>
);
}
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>
<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>
);
}
@@ -0,0 +1,75 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
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';
import { EmptyState } from '../../components/common/EmptyState';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
export function PlaylistDetailPage() {
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
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>;
}
if (playlistQuery.isError) {
return <ErrorState message="Failed to load playlist" onRetry={() => playlistQuery.refetch()} />;
}
const playlist = playlistQuery.data;
const tracks = tracksQuery.data ?? [];
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,
}));
};
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={{ 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>
</div>
<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." />
)}
{tracks.map((track, i) => (
<TrackRow key={`${track.id}-${i}`} track={track} index={i} showAlbum />
))}
</ScrollArea>
</div>
);
}
@@ -0,0 +1,4 @@
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>;
}
+4
View File
@@ -0,0 +1,4 @@
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>;
}
+4
View File
@@ -0,0 +1,4 @@
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>;
}
+5
View File
@@ -0,0 +1,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);
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useCallback } from 'react';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
pause, resume, setPosition, setDuration,
setVolume as setVolumeAction,
} from '../store/slices/player';
import { nextTrack, prevTrack } from '../store/slices/queue';
import { play } from '../store/slices/player';
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
let audioElement: HTMLAudioElement | null = null;
function getAudio(): HTMLAudioElement {
if (!audioElement) {
audioElement = new Audio();
audioElement.preload = 'metadata';
}
return audioElement;
}
export function useAudioPlayer() {
const dispatch = useAppDispatch();
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false);
useEffect(() => {
if (isSetup.current) return;
isSetup.current = true;
const audio = getAudio();
audio.addEventListener('timeupdate', () => {
dispatch(setPosition(audio.currentTime));
});
audio.addEventListener('durationchange', () => {
dispatch(setDuration(audio.duration || 0));
});
audio.addEventListener('ended', () => {
dispatch(nextTrack());
});
audio.addEventListener('pause', () => {
dispatch(pause());
});
audio.addEventListener('play', () => {
dispatch(resume());
});
}, [dispatch]);
useEffect(() => {
if (!player.currentTrackId || !accessToken) return;
const audio = getAudio();
const url = getStreamUrl(player.currentTrackId, accessToken);
if (audio.src !== url) {
audio.src = url;
audio.load();
}
if (player.isPlaying) {
void audio.play();
} else {
audio.pause();
}
}, [player.currentTrackId, player.isPlaying, accessToken]);
useEffect(() => {
const audio = getAudio();
audio.volume = player.muted ? 0 : player.volume;
}, [player.volume, player.muted]);
// MediaSession: system media controls + metadata (lock screen, OS, headset keys)
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const entry = queue.entries[queue.currentIndex];
if (!entry) {
navigator.mediaSession.metadata = null;
return;
}
const artUrl = getCoverUrl(entry.albumArtUrl);
navigator.mediaSession.metadata = new MediaMetadata({
title: entry.title,
artist: entry.artistName,
album: entry.albumTitle,
artwork: artUrl ? [{ src: artUrl }] : [],
});
}, [queue.entries, queue.currentIndex]);
useEffect(() => {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
}, [player.isPlaying]);
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => dispatch(resume()));
ms.setActionHandler('pause', () => dispatch(pause()));
ms.setActionHandler('previoustrack', () => dispatch(prevTrack()));
ms.setActionHandler('nexttrack', () => dispatch(nextTrack()));
ms.setActionHandler('seekto', (details) => {
if (typeof details.seekTime === 'number') {
getAudio().currentTime = details.seekTime;
dispatch(setPosition(details.seekTime));
}
});
return () => {
ms.setActionHandler('play', null);
ms.setActionHandler('pause', null);
ms.setActionHandler('previoustrack', null);
ms.setActionHandler('nexttrack', null);
ms.setActionHandler('seekto', null);
};
}, [dispatch]);
useEffect(() => {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry) return;
if (currentEntry.trackId !== player.currentTrackId) {
dispatch(play(currentEntry.trackId));
}
}, [queue.currentIndex, queue.entries, player.currentTrackId, 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 playNext = useCallback(() => { dispatch(nextTrack()); }, [dispatch]);
const playPrev = useCallback(() => { dispatch(prevTrack()); }, [dispatch]);
return { seek, setVolume: setPlayerVolume, playNext, playPrev };
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { getApiBaseUrl } from '../config/runtime-config';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export function useConnectionStatus() {
const [status, setStatus] = useState<ConnectionStatus>('connecting');
useEffect(() => {
let cancelled = false;
const check = async () => {
if (cancelled) return;
setStatus('connecting');
try {
const res = await fetch(`${getApiBaseUrl()}/health`, { signal: AbortSignal.timeout(5000) });
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
} catch {
if (!cancelled) setStatus('disconnected');
}
};
void check();
const interval = setInterval(() => { void check(); }, 30_000);
return () => { cancelled = true; clearInterval(interval); };
}, []);
return status;
}
+21
View File
@@ -0,0 +1,21 @@
import { useAppSelector } from './useAppDispatch';
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'],
user: ['download', 'upload'],
};
export function usePermissions() {
const user = useAppSelector((s) => s.auth.user);
const hasPermission = (permission: Permission): boolean => {
if (!user) return false;
return ROLE_PERMISSIONS[user.role]?.includes(permission) ?? false;
};
const isAdmin = user?.role === 'admin';
return { hasPermission, isAdmin, user };
}
+39
View File
@@ -0,0 +1,39 @@
import 'modern-sk/styles.css';
import 'modern-sk/fonts.css';
import './styles/global.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, TooltipProvider } from 'modern-sk';
import { store } from './store';
import { AppRoutes } from './routes';
// Import all endpoint injections to ensure they are registered
import './api/endpoints/auth';
import './api/endpoints/library';
import './api/endpoints/playlists';
import './api/endpoints/downloads';
import './api/endpoints/likes';
import './api/endpoints/storage';
import './api/endpoints/admin';
import './api/endpoints/upload';
const rootEl = document.getElementById('root');
if (rootEl) {
// grained black-ish background + base text color from modern-sk
rootEl.classList.add('modern-sk-felt');
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<ThemeProvider>
<TooltipProvider delayDuration={200}>
<AppRoutes />
</TooltipProvider>
</ThemeProvider>
</BrowserRouter>
</Provider>
</React.StrictMode>,
);
}
+21
View File
@@ -0,0 +1,21 @@
export function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
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')}`;
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`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function formatCount(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
+21
View File
@@ -0,0 +1,21 @@
import { Navigate } from 'react-router';
import { useAppSelector } from '../hooks/useAppDispatch';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
}
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
const auth = useAppSelector((s) => s.auth);
if (!auth.accessToken || !auth.user) {
return <Navigate to="/connect" replace />;
}
if (requireAdmin && auth.user.role !== 'admin') {
return <Navigate to="/library" replace />;
}
return <>{children}</>;
}
+51
View File
@@ -0,0 +1,51 @@
import { Routes, Route, Navigate } from 'react-router';
import { AppShell } from '../components/layout/AppShell';
import { ProtectedRoute } from './ProtectedRoute';
import { ConnectPage } from '../features/connect/ConnectPage';
import { HomePage } from '../features/home/HomePage';
import { LibraryPage } from '../features/library/LibraryPage';
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
import { PlaylistDetailPage } from '../features/playlist-detail/PlaylistDetailPage';
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 Fallback = () => <div style={{ padding: '2rem' }}><LoadingSkeleton /></div>;
export function AppRoutes() {
return (
<Routes>
<Route path="/connect" element={<ConnectPage />} />
<Route
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<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="/admin/*"
element={
<ProtectedRoute requireAdmin>
<Suspense fallback={<Fallback />}><AdminPage /></Suspense>
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { configureStore } from '@reduxjs/toolkit';
import { api } from '../api';
import authReducer from './slices/auth';
import playerReducer from './slices/player';
import queueReducer from './slices/queue';
import uiReducer from './slices/ui';
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: authReducer,
player: playerReducer,
queue: queueReducer,
ui: uiReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
+65
View File
@@ -0,0 +1,65 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { User } from '../../api/types';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
user: User | null;
}
const loadPersistedAuth = (): Partial<AuthState> => {
try {
const raw = localStorage.getItem('mcma_auth');
return raw ? (JSON.parse(raw) as Partial<AuthState>) : {};
} catch {
return {};
}
};
const persisted = loadPersistedAuth();
const initialState: AuthState = {
accessToken: persisted.accessToken ?? null,
refreshToken: persisted.refreshToken ?? null,
expiresAt: persisted.expiresAt ?? null,
user: persisted.user ?? null,
};
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
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;
persistAuth(state);
},
setUser(state, action: PayloadAction<User>) {
state.user = action.payload;
persistAuth(state);
},
logout(state) {
state.accessToken = null;
state.refreshToken = null;
state.expiresAt = null;
state.user = null;
localStorage.removeItem('mcma_auth');
},
},
});
function persistAuth(state: AuthState): void {
try {
localStorage.setItem('mcma_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;
export default authSlice.reducer;
+61
View File
@@ -0,0 +1,61 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type RepeatMode = 'none' | 'one' | 'all';
interface PlayerState {
currentTrackId: string | null;
isPlaying: boolean;
position: number;
duration: number;
volume: number;
muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isNowPlayingOpen: boolean;
isQueueOpen: boolean;
}
const initialState: PlayerState = {
currentTrackId: null,
isPlaying: false,
position: 0,
duration: 0,
volume: 0.8,
muted: false,
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
isQueueOpen: false,
};
export const playerSlice = createSlice({
name: 'player',
initialState,
reducers: {
play(state, action: PayloadAction<string>) {
state.currentTrackId = action.payload;
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; },
},
});
export const {
play, pause, resume, stop,
setPosition, setDuration,
setVolume, toggleMute,
setRepeat, toggleShuffle,
toggleNowPlaying, toggleQueue,
} = playerSlice.actions;
export default playerSlice.reducer;
+79
View File
@@ -0,0 +1,79 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type QueueSource = 'manual' | 'album' | 'playlist' | 'artist' | 'search';
interface QueueEntry {
trackId: string;
title: string;
artistName: string;
albumTitle: string;
durationMs: number;
albumArtUrl?: string;
}
interface QueueState {
entries: QueueEntry[];
currentIndex: number;
source: QueueSource;
sourceId: string | null;
sourceName: string | null;
}
const initialState: QueueState = {
entries: [],
currentIndex: -1,
source: 'manual',
sourceId: null,
sourceName: null,
};
export const queueSlice = createSlice({
name: 'queue',
initialState,
reducers: {
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;
state.sourceId = action.payload.sourceId ?? null;
state.sourceName = action.payload.sourceName ?? null;
},
addToQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.push(action.payload);
},
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.splice(state.currentIndex + 1, 0, action.payload);
},
removeFromQueue(state, action: PayloadAction<number>) {
state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--;
},
moveInQueue(state, action: PayloadAction<{ from: number; to: number }>) {
const { from, to } = action.payload;
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++;
},
goToIndex(state, action: PayloadAction<number>) {
state.currentIndex = action.payload;
},
nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++;
},
prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--;
},
clearQueue(state) {
state.entries = [];
state.currentIndex = -1;
},
},
});
export const {
setQueue, addToQueue, addNextInQueue, removeFromQueue,
moveInQueue, goToIndex, nextTrack, prevTrack, clearQueue,
} = queueSlice.actions;
export default queueSlice.reducer;
+28
View File
@@ -0,0 +1,28 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface UiState {
sidebarCollapsed: boolean;
activeModal: string | null;
activeTrackContextMenuId: string | null;
}
const initialState: UiState = {
sidebarCollapsed: false,
activeModal: null,
activeTrackContextMenuId: null,
};
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; },
},
});
export const { toggleSidebar, setSidebarCollapsed, openModal, closeModal, setActiveContextMenu } = uiSlice.actions;
export default uiSlice.reducer;
+39
View File
@@ -0,0 +1,39 @@
/*
* Global app styles bridging our component code to modern-sk tokens.
*
* Our components reference semantic --color-* names; modern-sk ships its own
* token names (--ink, --fg-*, --steel-*, --lime, --hair). These aliases map
* one to the other. They are `var()` references, so they resolve at the use
* site and follow the active [data-theme] (dark/light) automatically.
*/
:root {
--color-bg: var(--ink);
--color-surface-1: var(--ink-raised);
--color-surface-2: var(--steel-800);
--color-surface-3: var(--steel-700);
--color-text-1: var(--fg-1);
--color-text-2: var(--fg-2);
--color-text-3: var(--fg-3);
--color-accent: var(--lime);
--color-border: var(--hair);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--fg-1);
-webkit-font-smoothing: antialiased;
}
/* #root carries .modern-sk-felt (set in index.tsx) → grained --ink background.
Ensure scrollable content fills the viewport above the grain overlay. */
#root {
display: flex;
flex-direction: column;
}