Project started 🥂
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user