feat: auth & admin
This commit is contained in:
@@ -1,7 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Tabs, TabsList, TabsContent, SearchField, ScrollArea, Card } from 'modern-sk';
|
||||
import { useGetTracksQuery, useGetAlbumsQuery, useGetArtistsQuery } from '../../api/endpoints/library';
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
SearchField,
|
||||
ScrollArea,
|
||||
Card,
|
||||
} from 'modern-sk';
|
||||
import {
|
||||
useGetTracksQuery,
|
||||
useGetAlbumsQuery,
|
||||
useGetArtistsQuery,
|
||||
} from '../../api/endpoints/library';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
@@ -23,24 +34,37 @@ export function LibraryPage() {
|
||||
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
|
||||
|
||||
const handlePlayAll = (tracks: Track[]) => {
|
||||
dispatch(setQueue({
|
||||
entries: tracks.map((t) => ({
|
||||
trackId: t.id,
|
||||
title: t.title,
|
||||
artistName: t.artistName,
|
||||
albumTitle: t.albumTitle,
|
||||
durationMs: t.durationMs,
|
||||
albumArtUrl: t.albumArtUrl,
|
||||
})),
|
||||
source: 'manual',
|
||||
sourceName: 'Library',
|
||||
}));
|
||||
dispatch(
|
||||
setQueue({
|
||||
entries: tracks.map((t) => ({
|
||||
trackId: t.id,
|
||||
title: t.title,
|
||||
artistName: t.artistName,
|
||||
albumTitle: t.albumTitle,
|
||||
durationMs: t.durationMs,
|
||||
albumArtUrl: t.albumArtUrl,
|
||||
})),
|
||||
source: 'manual',
|
||||
sourceName: 'Library',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>Library</h2>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||
Library
|
||||
</h2>
|
||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||
<SearchField
|
||||
value={search}
|
||||
@@ -51,50 +75,116 @@ export function LibraryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={setTab} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '0 1.5rem', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
<TabsList items={[{ value: 'tracks', label: 'Tracks' }, { value: 'albums', label: 'Albums' }, { value: 'artists', label: 'Artists' }]} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '0 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
items={[
|
||||
{ value: 'tracks', label: 'Tracks' },
|
||||
{ value: 'albums', label: 'Albums' },
|
||||
{ value: 'artists', label: 'Artists' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
||||
{tracksQuery.isError && <ErrorState onRetry={() => tracksQuery.refetch()} />}
|
||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
||||
<EmptyState icon="♫" title="No tracks" description="Your library is empty. Start by downloading some music." />
|
||||
{tracksQuery.isError && (
|
||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||
)}
|
||||
{tracksQuery.data && tracksQuery.data.items.length > 0 && (() => {
|
||||
const data = tracksQuery.data!;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '0.5rem 0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'center', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<button
|
||||
onClick={() => handlePlayAll(data.items)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-accent)', fontSize: '0.875rem', fontWeight: 500 }}
|
||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
||||
<EmptyState
|
||||
icon="♫"
|
||||
title="No tracks"
|
||||
description="Your library is empty. Start by downloading some music."
|
||||
/>
|
||||
)}
|
||||
{tracksQuery.data &&
|
||||
tracksQuery.data.items.length > 0 &&
|
||||
(() => {
|
||||
const data = tracksQuery.data!;
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
▶ Play all ({data.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePlayAll(data.items)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
▶ Play all ({data.total})
|
||||
</button>
|
||||
</div>
|
||||
{data.items.map((track, i) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={i}
|
||||
showAlbum
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{data.items.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
||||
{albumsQuery.isError && <ErrorState onRetry={() => albumsQuery.refetch()} />}
|
||||
{albumsQuery.isError && (
|
||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||
)}
|
||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
||||
<EmptyState icon="💿" title="No albums" description="No albums in library." />
|
||||
<EmptyState
|
||||
icon="💿"
|
||||
title="No albums"
|
||||
description="No albums in library."
|
||||
/>
|
||||
)}
|
||||
{albumsQuery.data && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '1rem', padding: '1.25rem 1.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))',
|
||||
gap: '1rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
{albumsQuery.data.items.map((album) => (
|
||||
<AlbumCard key={album.id} album={album} onClick={() => void navigate(`/library/albums/${album.id}`)} />
|
||||
<AlbumCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
onClick={() => void navigate(`/library/albums/${album.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -104,9 +194,15 @@ export function LibraryPage() {
|
||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
||||
{artistsQuery.isError && <ErrorState onRetry={() => artistsQuery.refetch()} />}
|
||||
{artistsQuery.isError && (
|
||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||
)}
|
||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
||||
<EmptyState icon="🎤" title="No artists" description="No artists in library." />
|
||||
<EmptyState
|
||||
icon="🎤"
|
||||
title="No artists"
|
||||
description="No artists in library."
|
||||
/>
|
||||
)}
|
||||
{artistsQuery.data && (
|
||||
<div style={{ padding: '0.5rem 0' }}>
|
||||
@@ -127,17 +223,67 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
style={{ cursor: 'pointer', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '0.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{artUrl ? (
|
||||
<img src={artUrl} alt={album.title} style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 6 }} />
|
||||
<img
|
||||
src={artUrl}
|
||||
alt={album.title}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', aspectRatio: '1', background: 'var(--color-surface-3)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2rem' }}>💿</div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
background: 'var(--color-surface-3)',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
}}
|
||||
>
|
||||
💿
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.8125rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.title}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.artistName}</div>
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.8125rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-2)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{album.artistName}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
||||
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -145,11 +291,36 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||
|
||||
function ArtistRow({ artist }: { artist: Artist }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 1.5rem' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'var(--color-surface-3)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: '1.25rem' }}>🎤</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-surface-3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: '1.25rem',
|
||||
}}
|
||||
>
|
||||
🎤
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>{artist.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{artist.albumCount} albums · {artist.trackCount} tracks</div>
|
||||
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>
|
||||
{artist.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||
{artist.albumCount} albums · {artist.trackCount} tracks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user