stuff
This commit is contained in:
@@ -87,6 +87,19 @@ export const playlistsApi = api.injectEndpoints({
|
||||
{ type: 'Playlist', id: playlistId },
|
||||
],
|
||||
}),
|
||||
reorderPlaylistTracks: build.mutation<
|
||||
void,
|
||||
{ playlistId: string; trackIds: string[] }
|
||||
>({
|
||||
query: ({ playlistId, trackIds }) => ({
|
||||
url: `/playlists/${playlistId}/tracks/reorder`,
|
||||
method: 'PUT',
|
||||
body: { track_ids: trackIds },
|
||||
}),
|
||||
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||
{ type: 'Playlist', id: playlistId },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -100,4 +113,5 @@ export const {
|
||||
useDeletePlaylistMutation,
|
||||
useAddTrackToPlaylistMutation,
|
||||
useRemoveTrackFromPlaylistMutation,
|
||||
useReorderPlaylistTracksMutation,
|
||||
} = playlistsApi;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||
import { QueuePanel } from '../player/QueuePanel';
|
||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
|
||||
export function AppShell() {
|
||||
@@ -34,6 +35,7 @@ export function AppShell() {
|
||||
<QueuePanel />
|
||||
<TrackInfoDrawer />
|
||||
</div>
|
||||
<AddToPlaylistDialog />
|
||||
<PersistentPlayer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dialog, Button, TextField, Callout } from '@olly/modern-sk';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||
import { ErrorState } from '../common/ErrorState';
|
||||
import { EmptyState } from '../common/EmptyState';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { closeAddToPlaylist } from '../../store/slices/ui';
|
||||
import {
|
||||
useGetPlaylistsQuery,
|
||||
useAddTrackToPlaylistMutation,
|
||||
useCreatePlaylistMutation,
|
||||
} from '../../api/endpoints/playlists';
|
||||
|
||||
/**
|
||||
* Global "Add to playlist" dialog. Open state lives in `ui.addToPlaylistTrackId`
|
||||
* (mirrors TrackInfoDrawer's pattern); self-contained so it can be mounted once
|
||||
* in AppShell and triggered from any TrackContextMenu via `openAddToPlaylist`.
|
||||
*/
|
||||
export function AddToPlaylistDialog() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const trackId = useAppSelector((s) => s.ui.addToPlaylistTrackId);
|
||||
const isOpen = trackId !== null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) dispatch(closeAddToPlaylist());
|
||||
}}
|
||||
title={t('addToPlaylist.title')}
|
||||
>
|
||||
{trackId ? <AddToPlaylistContent trackId={trackId} /> : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToPlaylistContent({ trackId }: { trackId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
data: playlists,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useGetPlaylistsQuery();
|
||||
const [addTrackToPlaylist, { isLoading: isAdding }] =
|
||||
useAddTrackToPlaylistMutation();
|
||||
const [createPlaylist, { isLoading: isCreating }] =
|
||||
useCreatePlaylistMutation();
|
||||
|
||||
const [creatingNew, setCreatingNew] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [addedTo, setAddedTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const close = () => dispatch(closeAddToPlaylist());
|
||||
|
||||
const handleAdd = async (playlistId: string, playlistName: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
// Backend treats re-adding an already-present track as idempotent (204),
|
||||
// so any resolved mutation here counts as success.
|
||||
await addTrackToPlaylist({ playlistId, trackId }).unwrap();
|
||||
setAddedTo(playlistName);
|
||||
setTimeout(close, 900);
|
||||
} catch {
|
||||
setError(t('addToPlaylist.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAndAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setError(null);
|
||||
try {
|
||||
const playlist = await createPlaylist({ name }).unwrap();
|
||||
await handleAdd(playlist.id, playlist.name);
|
||||
} catch {
|
||||
setError(t('addToPlaylist.createError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||
>
|
||||
{addedTo ? (
|
||||
<Callout variant="success">
|
||||
{t('addToPlaylist.added', { name: addedTo })}
|
||||
</Callout>
|
||||
) : (
|
||||
<>
|
||||
{error && <Callout variant="danger">{error}</Callout>}
|
||||
|
||||
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{!isLoading && !isError && playlists && playlists.items.length === 0 && (
|
||||
<EmptyState title={t('addToPlaylist.empty')} />
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && playlists && playlists.items.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
maxHeight: '16rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{playlists.items.map((playlist) => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
type="button"
|
||||
disabled={isAdding || isCreating}
|
||||
onClick={() => void handleAdd(playlist.id, playlist.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.625rem',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text-1)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span>{playlist.name}</span>
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}
|
||||
>
|
||||
{playlist.trackCount}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creatingNew ? (
|
||||
<form
|
||||
onSubmit={(e) => void handleCreateAndAdd(e)}
|
||||
style={{ display: 'flex', gap: '0.5rem' }}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('addToPlaylist.newPlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!newName.trim() || isCreating || isAdding}
|
||||
>
|
||||
{t('addToPlaylist.create')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCreatingNew(true)}
|
||||
>
|
||||
<Icon name="plus" /> {t('addToPlaylist.newPlaylist')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type { Track } from '../../api/types';
|
||||
interface Props {
|
||||
track: Track;
|
||||
onAddToPlaylist?: (track: Track) => void;
|
||||
onRemoveFromPlaylist?: (track: Track) => void;
|
||||
onEditMetadata?: (track: Track) => void;
|
||||
onDelete?: (track: Track) => void;
|
||||
onDownload?: (track: Track) => void;
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
export function TrackContextMenu({
|
||||
track,
|
||||
onAddToPlaylist,
|
||||
onRemoveFromPlaylist,
|
||||
onEditMetadata,
|
||||
onDelete,
|
||||
onDownload,
|
||||
@@ -87,6 +89,11 @@ export function TrackContextMenu({
|
||||
{t('track.menu.addToPlaylist')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRemoveFromPlaylist && (
|
||||
<MenuItem onSelect={() => onRemoveFromPlaylist(track)}>
|
||||
{t('track.menu.removeFromPlaylist')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{onEditMetadata && (
|
||||
<MenuItem onSelect={() => onEditMetadata(track)}>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
* the header and per-track art would be redundant. */
|
||||
hideArt?: boolean;
|
||||
onAddToPlaylist?: (track: Track) => void;
|
||||
onRemoveFromPlaylist?: (track: Track) => void;
|
||||
onEditMetadata?: (track: Track) => void;
|
||||
onDelete?: (track: Track) => void;
|
||||
}
|
||||
@@ -32,6 +33,7 @@ export function TrackRow({
|
||||
showAlbum = false,
|
||||
hideArt = false,
|
||||
onAddToPlaylist,
|
||||
onRemoveFromPlaylist,
|
||||
onEditMetadata,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
@@ -190,6 +192,7 @@ export function TrackRow({
|
||||
<TrackContextMenu
|
||||
track={track}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onRemoveFromPlaylist={onRemoveFromPlaylist}
|
||||
onEditMetadata={onEditMetadata}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||
import {
|
||||
selectLocalAlbums,
|
||||
@@ -214,7 +215,13 @@ export function AlbumDetailPage() {
|
||||
/>
|
||||
)}
|
||||
{tracks.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} hideArt />
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={i}
|
||||
hideArt
|
||||
onAddToPlaylist={(t) => dispatch(openAddToPlaylist(t.id))}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||
import {
|
||||
selectLocalArtists,
|
||||
@@ -241,7 +242,13 @@ export function ArtistDetailPage() {
|
||||
/>
|
||||
)}
|
||||
{tracks.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={i}
|
||||
showAlbum
|
||||
onAddToPlaylist={(t) => dispatch(openAddToPlaylist(t.id))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Card,
|
||||
TextField,
|
||||
Callout,
|
||||
SearchField,
|
||||
} from '@olly/modern-sk';
|
||||
import {
|
||||
useGetTracksQuery,
|
||||
@@ -31,6 +32,7 @@ import type { Track, Album, Artist } from '../../api/types';
|
||||
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||
|
||||
/** Case-insensitive substring match used for client-side search while offline
|
||||
* (the server can't run the query, so we filter the locally-cached library). */
|
||||
@@ -143,7 +145,8 @@ export function LibraryPage() {
|
||||
{t('library.title')}
|
||||
</h2>
|
||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||
<TextField
|
||||
<SearchField
|
||||
icon={<MagnifyingGlassIcon />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('library.searchPlaceholder')}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
||||
import {
|
||||
ScrollArea,
|
||||
IconButton,
|
||||
Button,
|
||||
Dialog,
|
||||
AlertDialog,
|
||||
TextField,
|
||||
} from '@olly/modern-sk';
|
||||
import {
|
||||
useGetPlaylistQuery,
|
||||
useGetPlaylistTracksQuery,
|
||||
useUpdatePlaylistMutation,
|
||||
useDeletePlaylistMutation,
|
||||
useRemoveTrackFromPlaylistMutation,
|
||||
} from '../../api/endpoints/playlists';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
@@ -11,6 +22,7 @@ import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { setQueue } from '../../store/slices/queue';
|
||||
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
|
||||
export function PlaylistDetailPage() {
|
||||
@@ -25,6 +37,13 @@ export function PlaylistDetailPage() {
|
||||
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', {
|
||||
skip: !playlistId,
|
||||
});
|
||||
const [updatePlaylist] = useUpdatePlaylistMutation();
|
||||
const [deletePlaylist] = useDeletePlaylistMutation();
|
||||
const [removeTrackFromPlaylist] = useRemoveTrackFromPlaylistMutation();
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (playlistQuery.isLoading) {
|
||||
return (
|
||||
@@ -124,6 +143,23 @@ export function PlaylistDetailPage() {
|
||||
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
|
||||
</p>
|
||||
</div>
|
||||
{playlist && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRenameValue(playlist.name);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('playlist.menu.rename')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteOpen(true)}>
|
||||
{t('playlist.menu.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePlayAll}
|
||||
@@ -133,6 +169,63 @@ export function PlaylistDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{playlist && (
|
||||
<>
|
||||
<Dialog
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
title={t('playlists.renameDialog.title')}
|
||||
footer={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setRenameOpen(false)}>
|
||||
{t('playlists.newDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim()}
|
||||
onClick={async () => {
|
||||
const name = renameValue.trim();
|
||||
if (!name) return;
|
||||
await updatePlaylist({ id: playlist.id, name }).unwrap();
|
||||
setRenameOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('playlists.renameDialog.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title={t('playlists.deleteDialog.title')}
|
||||
description={t('playlists.deleteDialog.description', {
|
||||
name: playlist.name,
|
||||
})}
|
||||
cancelLabel={t('playlists.deleteDialog.cancel')}
|
||||
actionLabel={t('playlists.deleteDialog.confirm')}
|
||||
destructive
|
||||
onAction={() => {
|
||||
void deletePlaylist(playlist.id);
|
||||
void navigate('/playlists');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
||||
{tracksQuery.isError && (
|
||||
@@ -156,6 +249,11 @@ export function PlaylistDetailPage() {
|
||||
track={track}
|
||||
index={i}
|
||||
showAlbum
|
||||
onAddToPlaylist={(tr) => dispatch(openAddToPlaylist(tr.id))}
|
||||
onRemoveFromPlaylist={(tr) => {
|
||||
if (!playlistId) return;
|
||||
void removeTrackFromPlaylist({ playlistId, trackId: tr.id });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,8 +1,273 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
AlertDialog,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
} from '@olly/modern-sk';
|
||||
import { Icon } from '../../components/common/Icon';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import {
|
||||
useGetPlaylistsQuery,
|
||||
useCreatePlaylistMutation,
|
||||
useUpdatePlaylistMutation,
|
||||
useDeletePlaylistMutation,
|
||||
} from '../../api/endpoints/playlists';
|
||||
import type { Playlist } from '../../api/types';
|
||||
|
||||
/** `/playlists` — user's playlist list. Scaffold only. */
|
||||
/** `/playlists` — user's playlist list. */
|
||||
export function PlaylistsPage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.playlists')} />;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useGetPlaylistsQuery();
|
||||
const [createPlaylist, { isLoading: isCreating }] =
|
||||
useCreatePlaylistMutation();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const playlist = await createPlaylist({ name }).unwrap();
|
||||
setCreateOpen(false);
|
||||
setNewName('');
|
||||
void navigate(`/playlists/${playlist.id}`);
|
||||
};
|
||||
|
||||
const playlists = data?.items ?? [];
|
||||
|
||||
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, flex: 1 }}>
|
||||
{t('nav.playlists')}
|
||||
</h2>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) setNewName('');
|
||||
}}
|
||||
title={t('playlists.newDialog.title')}
|
||||
trigger={
|
||||
<Button variant="primary">
|
||||
<Icon name="plus" /> {t('nav.newPlaylist')}
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||
{t('playlists.newDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!newName.trim() || isCreating}
|
||||
onClick={(e) => void handleCreate(e)}
|
||||
>
|
||||
{t('playlists.newDialog.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={(e) => void handleCreate(e)}>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('playlists.newDialog.placeholder')}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||
{isLoading && <LoadingSkeleton rows={6} height={64} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{!isLoading && !isError && playlists.length === 0 && (
|
||||
<EmptyState
|
||||
icon="♫"
|
||||
title={t('playlists.empty.title')}
|
||||
description={t('playlists.empty.description')}
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
<Icon name="plus" /> {t('nav.newPlaylist')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && playlists.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(16rem, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{playlists.map((playlist) => (
|
||||
<PlaylistCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
onOpen={() => void navigate(`/playlists/${playlist.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaylistCard({
|
||||
playlist,
|
||||
onOpen,
|
||||
}: {
|
||||
playlist: Playlist;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [updatePlaylist] = useUpdatePlaylistMutation();
|
||||
const [deletePlaylist] = useDeletePlaylistMutation();
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(playlist.name);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const handleRename = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = renameValue.trim();
|
||||
if (!name) return;
|
||||
await updatePlaylist({ id: playlist.id, name }).unwrap();
|
||||
setRenameOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{playlist.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('playlists.trackCount', { count: playlist.trackCount })}
|
||||
{playlist.trackCount > 0 &&
|
||||
` · ${formatDuration(playlist.totalDurationMs)}`}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('playlists.menu.options')}
|
||||
>
|
||||
⋯
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setRenameValue(playlist.name);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('playlists.menu.rename')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => setDeleteOpen(true)}>
|
||||
{t('playlists.menu.delete')}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
title={t('playlists.renameDialog.title')}
|
||||
footer={
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setRenameOpen(false)}>
|
||||
{t('playlists.newDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim()}
|
||||
onClick={(e) => void handleRename(e)}
|
||||
>
|
||||
{t('playlists.renameDialog.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={(e) => void handleRename(e)}>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title={t('playlists.deleteDialog.title')}
|
||||
description={t('playlists.deleteDialog.description', {
|
||||
name: playlist.name,
|
||||
})}
|
||||
cancelLabel={t('playlists.deleteDialog.cancel')}
|
||||
actionLabel={t('playlists.deleteDialog.confirm')}
|
||||
destructive
|
||||
onAction={() => void deletePlaylist(playlist.id)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import type { ExternalSearchResult } from '../../api/types';
|
||||
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
@@ -135,7 +136,7 @@ export function SearchDownloadPage() {
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<SearchField
|
||||
icon={<Icon name="magnifying-glass" />}
|
||||
icon={<MagnifyingGlassIcon />}
|
||||
placeholder={t('discover.searchPlaceholder')}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge'
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||
|
||||
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||
@@ -53,6 +55,7 @@ function extractError(err: unknown): string {
|
||||
export function UploadPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [uploadTrack] = useUploadTrackMutation();
|
||||
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
@@ -284,7 +287,13 @@ export function UploadPage() {
|
||||
</p>
|
||||
)}
|
||||
{recentQuery.data?.items.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={i}
|
||||
showAlbum
|
||||
onAddToPlaylist={(t) => dispatch(openAddToPlaylist(t.id))}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,10 @@ const en = {
|
||||
title: 'Empty playlist',
|
||||
description: 'This playlist has no tracks yet.',
|
||||
},
|
||||
menu: {
|
||||
rename: 'Rename',
|
||||
delete: 'Delete',
|
||||
},
|
||||
},
|
||||
player: {
|
||||
nothingPlaying: 'Nothing playing',
|
||||
@@ -192,11 +196,50 @@ const en = {
|
||||
addToQueue: 'Add to queue',
|
||||
info: 'Track info',
|
||||
addToPlaylist: 'Add to playlist…',
|
||||
removeFromPlaylist: 'Remove from playlist',
|
||||
editMetadata: 'Edit metadata',
|
||||
download: 'Download',
|
||||
delete: 'Delete',
|
||||
},
|
||||
},
|
||||
addToPlaylist: {
|
||||
title: 'Add to playlist',
|
||||
empty: 'You have no playlists yet.',
|
||||
newPlaylist: 'New playlist',
|
||||
newPlaceholder: 'Playlist name',
|
||||
create: 'Create & add',
|
||||
added: 'Added to "{{name}}".',
|
||||
error: 'Could not add the track to that playlist. Try again.',
|
||||
createError: 'Could not create the playlist. Try again.',
|
||||
},
|
||||
playlists: {
|
||||
newDialog: {
|
||||
title: 'New playlist',
|
||||
placeholder: 'Playlist name',
|
||||
cancel: 'Cancel',
|
||||
create: 'Create',
|
||||
},
|
||||
empty: {
|
||||
title: 'No playlists yet',
|
||||
description: 'Create your first playlist to start organizing tracks.',
|
||||
},
|
||||
trackCount: '{{count}} tracks',
|
||||
menu: {
|
||||
options: 'Playlist options',
|
||||
rename: 'Rename',
|
||||
delete: 'Delete',
|
||||
},
|
||||
renameDialog: {
|
||||
title: 'Rename playlist',
|
||||
save: 'Save',
|
||||
},
|
||||
deleteDialog: {
|
||||
title: 'Delete playlist?',
|
||||
description: 'This permanently deletes "{{name}}". Tracks stay in your library.',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Delete',
|
||||
},
|
||||
},
|
||||
trackInfo: {
|
||||
title: 'Track info',
|
||||
open: 'View track info',
|
||||
|
||||
@@ -147,6 +147,10 @@ const ru: Translations = {
|
||||
title: 'Плейлист пуст',
|
||||
description: 'В этом плейлисте пока нет треков.',
|
||||
},
|
||||
menu: {
|
||||
rename: 'Переименовать',
|
||||
delete: 'Удалить',
|
||||
},
|
||||
},
|
||||
player: {
|
||||
nothingPlaying: 'Ничего не играет',
|
||||
@@ -193,11 +197,50 @@ const ru: Translations = {
|
||||
addToQueue: 'Добавить в очередь',
|
||||
info: 'Информация о треке',
|
||||
addToPlaylist: 'Добавить в плейлист…',
|
||||
removeFromPlaylist: 'Убрать из плейлиста',
|
||||
editMetadata: 'Редактировать метаданные',
|
||||
download: 'Скачать',
|
||||
delete: 'Удалить',
|
||||
},
|
||||
},
|
||||
addToPlaylist: {
|
||||
title: 'Добавить в плейлист',
|
||||
empty: 'У вас пока нет плейлистов.',
|
||||
newPlaylist: 'Новый плейлист',
|
||||
newPlaceholder: 'Название плейлиста',
|
||||
create: 'Создать и добавить',
|
||||
added: 'Добавлено в «{{name}}».',
|
||||
error: 'Не удалось добавить трек в этот плейлист. Попробуйте ещё раз.',
|
||||
createError: 'Не удалось создать плейлист. Попробуйте ещё раз.',
|
||||
},
|
||||
playlists: {
|
||||
newDialog: {
|
||||
title: 'Новый плейлист',
|
||||
placeholder: 'Название плейлиста',
|
||||
cancel: 'Отмена',
|
||||
create: 'Создать',
|
||||
},
|
||||
empty: {
|
||||
title: 'Пока нет плейлистов',
|
||||
description: 'Создайте первый плейлист, чтобы начать организовывать треки.',
|
||||
},
|
||||
trackCount: '{{count}} треков',
|
||||
menu: {
|
||||
options: 'Действия с плейлистом',
|
||||
rename: 'Переименовать',
|
||||
delete: 'Удалить',
|
||||
},
|
||||
renameDialog: {
|
||||
title: 'Переименовать плейлист',
|
||||
save: 'Сохранить',
|
||||
},
|
||||
deleteDialog: {
|
||||
title: 'Удалить плейлист?',
|
||||
description: 'Плейлист «{{name}}» будет удалён навсегда. Треки останутся в библиотеке.',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Удалить',
|
||||
},
|
||||
},
|
||||
trackInfo: {
|
||||
title: 'О треке',
|
||||
open: 'Информация о треке',
|
||||
|
||||
@@ -6,6 +6,8 @@ interface UiState {
|
||||
activeTrackContextMenuId: string | null;
|
||||
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||
trackInfoId: string | null;
|
||||
/** Track for which the "Add to playlist" dialog is open; null = closed. */
|
||||
addToPlaylistTrackId: string | null;
|
||||
}
|
||||
|
||||
const initialState: UiState = {
|
||||
@@ -13,6 +15,7 @@ const initialState: UiState = {
|
||||
activeModal: null,
|
||||
activeTrackContextMenuId: null,
|
||||
trackInfoId: null,
|
||||
addToPlaylistTrackId: null,
|
||||
};
|
||||
|
||||
export const uiSlice = createSlice({
|
||||
@@ -40,6 +43,12 @@ export const uiSlice = createSlice({
|
||||
closeTrackInfo(state) {
|
||||
state.trackInfoId = null;
|
||||
},
|
||||
openAddToPlaylist(state, action: PayloadAction<string>) {
|
||||
state.addToPlaylistTrackId = action.payload;
|
||||
},
|
||||
closeAddToPlaylist(state) {
|
||||
state.addToPlaylistTrackId = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,5 +60,7 @@ export const {
|
||||
setActiveContextMenu,
|
||||
openTrackInfo,
|
||||
closeTrackInfo,
|
||||
openAddToPlaylist,
|
||||
closeAddToPlaylist,
|
||||
} = uiSlice.actions;
|
||||
export default uiSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user