feat: auth & admin

This commit is contained in:
2026-06-03 10:41:53 +03:00
parent 612d0f0125
commit 7dc59fb3c4
120 changed files with 4683 additions and 2159 deletions
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function AdminPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Admin"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+128 -29
View File
@@ -1,6 +1,9 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useGetAlbumQuery, useGetAlbumTracksQuery } from '../../api/endpoints/library';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
@@ -19,11 +22,20 @@ export function AlbumDetailPage() {
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (albumQuery.isError) {
return <ErrorState message="Failed to load album" onRetry={() => albumQuery.refetch()} />;
return (
<ErrorState
message="Failed to load album"
onRetry={() => albumQuery.refetch()}
/>
);
}
const album = albumQuery.data;
@@ -32,52 +44,139 @@ export function AlbumDetailPage() {
const handlePlayAll = () => {
if (!tracks.length || !album) return;
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'album',
sourceId: album.id,
sourceName: album.title,
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'album',
sourceId: album.id,
sourceName: album.title,
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back"></IconButton>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-end', flex: 1 }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
>
</IconButton>
<div
style={{
display: 'flex',
gap: '1.5rem',
alignItems: 'flex-end',
flex: 1,
}}
>
{artUrl ? (
<img src={artUrl} alt={album?.title} width={96} height={96} style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />
<img
src={artUrl}
alt={album?.title}
width={96}
height={96}
style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }}
/>
) : (
<div style={{ width: 96, height: 96, borderRadius: 8, background: 'var(--color-surface-3)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2.5rem' }}>💿</div>
<div
style={{
width: 96,
height: 96,
borderRadius: 8,
background: 'var(--color-surface-3)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
}}
>
💿
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Album</p>
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{album?.title}</h1>
<p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Album
</p>
<h1
style={{
margin: '0.25rem 0',
fontSize: '1.5rem',
fontWeight: 700,
}}
>
{album?.title}
</h1>
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album && ` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}> Play</Button>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
<EmptyState icon="♫" title="No tracks" description="This album has no tracks." />
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />
))}
+186 -22
View File
@@ -1,24 +1,48 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout } from 'modern-sk';
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl, getApiBaseUrl } from '../../config/runtime-config';
import { setApiBaseUrl } from '../../config/runtime-config';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
} from '../../config/instances';
import type { User } from '../../api/types';
export function ConnectPage() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [apiUrl, setApiUrl] = useState(getApiBaseUrl);
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// STUB: no backend yet. Fake a session so the rest of the app is reachable.
// Replace with the real useLoginMutation() flow once the backend exists.
// Switching to a saved backend reloads the app so every slice re-initialises
// from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const forget = (id: string) => {
removeInstance(id);
setRev((r) => r + 1);
};
// STUB: no backend yet. Register the instance, then fake a session so the rest
// of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl);
setApiBaseUrl(apiUrl); // upsert + activate this backend
const fakeUser: User = {
id: 'dev-user',
@@ -26,21 +50,158 @@ export function ConnectPage() {
role: 'admin',
createdAt: new Date().toISOString(),
};
dispatch(setTokens({ accessToken: 'dev-token', refreshToken: 'dev-refresh', expiresIn: 3600 }));
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
void navigate('/');
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)', padding: '2rem' }}>
<div style={{ width: '100%', maxWidth: '24rem' }}>
<h1 style={{ textAlign: 'center', marginBottom: '2rem', color: 'var(--color-accent)', fontSize: '1.75rem' }}> MCMA</h1>
<div
key={rev}
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem',
}}
>
<div
style={{
width: '100%',
maxWidth: '26rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1
style={{
textAlign: 'center',
color: 'var(--color-accent)',
fontSize: '1.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
<Icon name="vinyl-record" fill /> MCMA
</h1>
{instances.length > 0 && (
<Card>
<div
style={{
padding: '1.25rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
</span>
{instances.map((inst) => (
<div
key={inst.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
>
<Icon name="trash" />
</button>
</div>
))}
</div>
</Card>
)}
<Card>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1.5rem' }}>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Server URL
</label>
<label style={labelStyle}>Server URL</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
@@ -49,9 +210,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Username
</label>
<label style={labelStyle}>Username</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -61,9 +220,7 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)' }}>
Password
</label>
<label style={labelStyle}>Password</label>
<TextField
type="password"
value={password}
@@ -73,8 +230,15 @@ export function ConnectPage() {
required
/>
</div>
<Callout variant="warning">Stub mode backend not wired. Connect signs in with a fake admin session.</Callout>
<Button type="submit" variant="primary" style={{ marginTop: '0.5rem' }}>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
</Callout>
<Button
type="submit"
variant="primary"
style={{ marginTop: '0.5rem' }}
>
Connect
</Button>
</form>
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function DownloadsManagerPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Downloads"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Downloads">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+258 -46
View File
@@ -1,11 +1,47 @@
import { useState } from 'react';
import {
Button, IconButton, TextField, TextArea, SearchField, Select,
Switch, Checkbox, RadioGroup, RadioItem, Control, SegmentedControl,
Slider, Stepper, Tabs, TabsList, TabsContent, Progress, Badge, Chip,
Card, List, Row, Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator,
Tooltip, Spinner, Callout, Table, THead, TBody, Tr, Th, Td,
Dialog, DialogClose, AlertDialog, Window, useTheme,
Button,
IconButton,
TextField,
TextArea,
SearchField,
Select,
Switch,
Checkbox,
RadioGroup,
RadioItem,
Control,
SegmentedControl,
Slider,
Stepper,
Tabs,
TabsList,
TabsContent,
Progress,
Badge,
Chip,
Card,
List,
Row,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Tooltip,
Spinner,
Callout,
Table,
THead,
TBody,
Tr,
Th,
Td,
Dialog,
DialogClose,
AlertDialog,
Window,
useTheme,
} from 'modern-sk';
const sectionStyle: React.CSSProperties = {
@@ -29,7 +65,13 @@ const labelStyle: React.CSSProperties = {
color: 'var(--color-text-3)',
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
<span style={labelStyle}>{title}</span>
@@ -52,16 +94,42 @@ export function HomePage() {
return (
<div style={{ overflow: 'auto', height: '100%' }}>
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem', maxWidth: '64rem', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: '64rem',
margin: '0 auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}> MCMA Component Kitchen Sink</h1>
<p style={{ margin: '0.25rem 0 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>
MCMA Component Kitchen Sink
</h1>
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
modern-sk reference. Project base ready for development.
</p>
</div>
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'}`}>
<Button variant="ghost" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
<Button
variant="ghost"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</Button>
</Tooltip>
@@ -73,22 +141,45 @@ export function HomePage() {
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" disabled>Disabled</Button>
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
<div style={rowWrap}>
<IconButton variant="primary" aria-label="Play"></IconButton>
<IconButton variant="ghost" aria-label="Next"></IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop"></IconButton>
<Stepper onDecrement={() => setCount((c) => c - 1)} onIncrement={() => setCount((c) => c + 1)} />
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>count: {count}</span>
<IconButton variant="primary" aria-label="Play">
</IconButton>
<IconButton variant="ghost" aria-label="Next">
</IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop">
</IconButton>
<Stepper
onDecrement={() => setCount((c) => c - 1)}
onIncrement={() => setCount((c) => c + 1)}
/>
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}
>
count: {count}
</span>
</div>
</Section>
<Section title="Inputs">
<div style={rowWrap}>
<TextField placeholder="Text field" style={{ width: '14rem' }} />
<SearchField icon="⌕" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search…" style={{ width: '14rem' }} />
<SearchField
icon="⌕"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
style={{ width: '14rem' }}
/>
<Select
placeholder="Pick genre"
aria-label="Genre"
@@ -106,10 +197,20 @@ export function HomePage() {
<Section title="Toggles & selection">
<div style={rowWrap}>
<Control control={<Switch checked={switchOn} onCheckedChange={setSwitchOn} />}>Switch</Control>
<Control
control={
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
}
>
Switch
</Control>
<Control control={<Checkbox defaultChecked />}>Checkbox</Control>
</div>
<RadioGroup value={radio} onValueChange={setRadio} style={{ display: 'flex', gap: '1rem' }}>
<RadioGroup
value={radio}
onValueChange={setRadio}
style={{ display: 'flex', gap: '1rem' }}
>
<Control control={<RadioItem value="a" />}>Option A</Control>
<Control control={<RadioItem value="b" />}>Option B</Control>
<Control control={<RadioItem value="c" />}>Option C</Control>
@@ -126,15 +227,28 @@ export function HomePage() {
</Section>
<Section title="Sliders & progress">
<Slider min={0} max={100} step={1} value={vol} onValueChange={setVol} marks notches="bottom" />
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>value: {vol[0]}</span>
<Slider
min={0}
max={100}
step={1}
value={vol}
onValueChange={setVol}
notches="bottom"
/>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>
value: {vol[0]}
</span>
<Progress value={vol[0]} />
</Section>
<Section title="Badges, chips, spinner">
<div style={rowWrap}>
<Badge variant="lime" dot>On server</Badge>
<Badge variant="ember" dot>Error</Badge>
<Badge variant="lime" dot>
On server
</Badge>
<Badge variant="ember" dot>
Error
</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Spinner size="sm" />
@@ -142,45 +256,118 @@ export function HomePage() {
</div>
<div style={rowWrap}>
{chips.map((c) => (
<Chip key={c} onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}>{c}</Chip>
<Chip
key={c}
onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}
>
{c}
</Chip>
))}
{chips.length === 0 && <span style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}>all removed</span>}
{chips.length === 0 && (
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}
>
all removed
</span>
)}
</div>
</Section>
<Section title="Callouts">
<Callout variant="info">Info backend address resolves from runtime env relative /api/v1.</Callout>
<Callout variant="success">Success typecheck and lint pass clean.</Callout>
<Callout variant="warning">Warning most feature screens are still stubs.</Callout>
<Callout variant="danger">Danger destructive actions use AlertDialog.</Callout>
<Callout variant="info">
Info backend address resolves from runtime env relative
/api/v1.
</Callout>
<Callout variant="success">
Success typecheck and lint pass clean.
</Callout>
<Callout variant="warning">
Warning most feature screens are still stubs.
</Callout>
<Callout variant="danger">
Danger destructive actions use AlertDialog.
</Callout>
</Section>
<Section title="Tabs">
<Tabs value={tab} onValueChange={setTab}>
<TabsList items={[{ value: 'one', label: 'First' }, { value: 'two', label: 'Second' }, { value: 'three', label: 'Third' }]} />
<TabsContent value="one" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>First panel</TabsContent>
<TabsContent value="two" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>Second panel</TabsContent>
<TabsContent value="three" style={{ padding: '0.75rem 0', color: 'var(--color-text-2)', fontSize: '0.875rem' }}>Third panel</TabsContent>
<TabsList
items={[
{ value: 'one', label: 'First' },
{ value: 'two', label: 'Second' },
{ value: 'three', label: 'Third' },
]}
/>
<TabsContent
value="one"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
First panel
</TabsContent>
<TabsContent
value="two"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Second panel
</TabsContent>
<TabsContent
value="three"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Third panel
</TabsContent>
</Tabs>
</Section>
<Section title="List & rows">
<List>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track one Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>Track two Artist (selected)</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track three Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>
Track two Artist (selected)
</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>
Track three Artist
</Row>
</List>
</Section>
<Section title="Table">
<Table>
<THead>
<Tr><Th>Title</Th><Th>Artist</Th><Th>Duration</Th></Tr>
<Tr>
<Th>Title</Th>
<Th>Artist</Th>
<Th>Duration</Th>
</Tr>
</THead>
<TBody>
<Tr><Td>Intro</Td><Td>Aphex</Td><Td>2:14</Td></Tr>
<Tr selected><Td>Windowlicker</Td><Td>Aphex</Td><Td>6:07</Td></Tr>
<Tr><Td>Avril 14th</Td><Td>Aphex</Td><Td>2:01</Td></Tr>
<Tr>
<Td>Intro</Td>
<Td>Aphex</Td>
<Td>2:14</Td>
</Tr>
<Tr selected>
<Td>Windowlicker</Td>
<Td>Aphex</Td>
<Td>6:07</Td>
</Tr>
<Tr>
<Td>Avril 14th</Td>
<Td>Aphex</Td>
<Td>2:01</Td>
</Tr>
</TBody>
</Table>
</Section>
@@ -203,9 +390,21 @@ export function HomePage() {
trigger={<Button variant="primary">Open dialog</Button>}
title="Dialog title"
description="Composed from modern-sk primitives."
footer={<DialogClose asChild><Button variant="primary">Done</Button></DialogClose>}
footer={
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
}
>
<p style={{ color: 'var(--color-text-2)', fontSize: '0.875rem', margin: 0 }}>Dialog body content.</p>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Dialog body content.
</p>
</Dialog>
<AlertDialog
@@ -220,8 +419,21 @@ export function HomePage() {
</Section>
<Section title="Window">
<Window title="Now Playing" badge={<Badge variant="lime" dot>live</Badge>}>
<p style={{ color: 'var(--color-text-2)', fontSize: '0.875rem', margin: 0 }}>
<Window
title="Now Playing"
badge={
<Badge variant="lime" dot>
live
</Badge>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Window chrome for grouped content.
</p>
</Window>
+225 -54
View File
@@ -1,7 +1,18 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Tabs, TabsList, TabsContent, SearchField, ScrollArea, Card } from 'modern-sk';
import { useGetTracksQuery, useGetAlbumsQuery, useGetArtistsQuery } from '../../api/endpoints/library';
import {
Tabs,
TabsList,
TabsContent,
SearchField,
ScrollArea,
Card,
} from 'modern-sk';
import {
useGetTracksQuery,
useGetAlbumsQuery,
useGetArtistsQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
@@ -23,24 +34,37 @@ export function LibraryPage() {
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
const handlePlayAll = (tracks: Track[]) => {
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>Library</h2>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
@@ -51,50 +75,116 @@ export function LibraryPage() {
</div>
</div>
<Tabs value={tab} onValueChange={setTab} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '0 1.5rem', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<TabsList items={[{ value: 'tracks', label: 'Tracks' }, { value: 'albums', label: 'Albums' }, { value: 'artists', label: 'Artists' }]} />
<Tabs
value={tab}
onValueChange={setTab}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div
style={{
padding: '0 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<TabsList
items={[
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
]}
/>
</div>
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
{tracksQuery.isError && <ErrorState onRetry={() => tracksQuery.refetch()} />}
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState icon="♫" title="No tracks" description="Your library is empty. Start by downloading some music." />
{tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} />
)}
{tracksQuery.data && tracksQuery.data.items.length > 0 && (() => {
const data = tracksQuery.data!;
return (
<div>
<div style={{ padding: '0.5rem 0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'center', borderBottom: '1px solid var(--color-border)' }}>
<button
onClick={() => handlePlayAll(data.items)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-accent)', fontSize: '0.875rem', fontWeight: 500 }}
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="Your library is empty. Start by downloading some music."
/>
)}
{tracksQuery.data &&
tracksQuery.data.items.length > 0 &&
(() => {
const data = tracksQuery.data!;
return (
<div>
<div
style={{
padding: '0.5rem 0.75rem',
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
borderBottom: '1px solid var(--color-border)',
}}
>
Play all ({data.total})
</button>
<button
onClick={() => handlePlayAll(data.items)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-accent)',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Play all ({data.total})
</button>
</div>
{data.items.map((track, i) => (
<TrackRow
key={track.id}
track={track}
index={i}
showAlbum
/>
))}
</div>
{data.items.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} showAlbum />
))}
</div>
);
})()}
);
})()}
</ScrollArea>
</TabsContent>
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
{albumsQuery.isError && <ErrorState onRetry={() => albumsQuery.refetch()} />}
{albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} />
)}
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState icon="💿" title="No albums" description="No albums in library." />
<EmptyState
icon="💿"
title="No albums"
description="No albums in library."
/>
)}
{albumsQuery.data && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '1rem', padding: '1.25rem 1.5rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))',
gap: '1rem',
padding: '1.25rem 1.5rem',
}}
>
{albumsQuery.data.items.map((album) => (
<AlbumCard key={album.id} album={album} onClick={() => void navigate(`/library/albums/${album.id}`)} />
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/library/albums/${album.id}`)}
/>
))}
</div>
)}
@@ -104,9 +194,15 @@ export function LibraryPage() {
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
{artistsQuery.isError && <ErrorState onRetry={() => artistsQuery.refetch()} />}
{artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} />
)}
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState icon="🎤" title="No artists" description="No artists in library." />
<EmptyState
icon="🎤"
title="No artists"
description="No artists in library."
/>
)}
{artistsQuery.data && (
<div style={{ padding: '0.5rem 0' }}>
@@ -127,17 +223,67 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
return (
<Card
onClick={onClick}
style={{ cursor: 'pointer', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
style={{
cursor: 'pointer',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{artUrl ? (
<img src={artUrl} alt={album.title} style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 6 }} />
<img
src={artUrl}
alt={album.title}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: 6,
}}
/>
) : (
<div style={{ width: '100%', aspectRatio: '1', background: 'var(--color-surface-3)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2rem' }}>💿</div>
<div
style={{
width: '100%',
aspectRatio: '1',
background: 'var(--color-surface-3)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
}}
>
💿
</div>
)}
<div>
<div style={{ fontWeight: 600, fontSize: '0.8125rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.title}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{album.artistName}</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}</div>
<div
style={{
fontWeight: 600,
fontSize: '0.8125rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.title}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.artistName}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
</div>
</div>
</Card>
);
@@ -145,11 +291,36 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
function ArtistRow({ artist }: { artist: Artist }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 1.5rem' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'var(--color-surface-3)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: '1.25rem' }}>🎤</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 1.5rem',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--color-surface-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '1.25rem',
}}
>
🎤
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>{artist.name}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{artist.albumCount} albums · {artist.trackCount} tracks</div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{artist.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{artist.albumCount} albums · {artist.trackCount} tracks
</div>
</div>
</div>
);
@@ -1,6 +1,9 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useGetPlaylistQuery, useGetPlaylistTracksQuery } from '../../api/endpoints/playlists';
import {
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
} from '../../api/endpoints/playlists';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
@@ -14,15 +17,28 @@ export function PlaylistDetailPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const playlistQuery = useGetPlaylistQuery(playlistId ?? '', { skip: !playlistId });
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', { skip: !playlistId });
const playlistQuery = useGetPlaylistQuery(playlistId ?? '', {
skip: !playlistId,
});
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', {
skip: !playlistId,
});
if (playlistQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (playlistQuery.isError) {
return <ErrorState message="Failed to load playlist" onRetry={() => playlistQuery.refetch()} />;
return (
<ErrorState
message="Failed to load playlist"
onRetry={() => playlistQuery.refetch()}
/>
);
}
const playlist = playlistQuery.data;
@@ -30,44 +46,115 @@ export function PlaylistDetailPage() {
const handlePlayAll = () => {
if (!tracks.length || !playlist) return;
dispatch(setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'playlist',
sourceId: playlist.id,
sourceName: playlist.name,
}));
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'playlist',
sourceId: playlist.id,
sourceName: playlist.name,
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back"></IconButton>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
>
</IconButton>
<div style={{ flex: 1 }}>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Playlist</p>
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{playlist?.name}</h1>
{playlist?.description && <p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>{playlist.description}</p>}
<p style={{ margin: '0.25rem 0 0', color: 'var(--color-text-3)', fontSize: '0.8125rem' }}>
{playlist && `${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Playlist
</p>
<h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
>
{playlist?.name}
</h1>
{playlist?.description && (
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{playlist.description}
</p>
)}
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-3)',
fontSize: '0.8125rem',
}}
>
{playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
</p>
</div>
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}> Play</Button>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
</Button>
</div>
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
<EmptyState icon="♫" title="Empty playlist" description="This playlist has no tracks yet." />
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
<EmptyState
icon="♫"
title="Empty playlist"
description="This playlist has no tracks yet."
/>
)}
{tracks.map((track, i) => (
<TrackRow key={`${track.id}-${i}`} track={track} index={i} showAlbum />
<TrackRow
key={`${track.id}-${i}`}
track={track}
index={i}
showAlbum
/>
))}
</ScrollArea>
</div>
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function SearchDownloadPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Search & Download"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Search & Download">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function SettingsPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Settings"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Settings">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function StoragePage() {
return <div style={{ padding: '1.5rem' }}><Window title="Storage"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Storage">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}