Project started 🥂
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: () => ({}),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_BASE_URL?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user