stuff
This commit is contained in:
@@ -87,6 +87,19 @@ export const playlistsApi = api.injectEndpoints({
|
|||||||
{ type: 'Playlist', id: playlistId },
|
{ 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,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -100,4 +113,5 @@ export const {
|
|||||||
useDeletePlaylistMutation,
|
useDeletePlaylistMutation,
|
||||||
useAddTrackToPlaylistMutation,
|
useAddTrackToPlaylistMutation,
|
||||||
useRemoveTrackFromPlaylistMutation,
|
useRemoveTrackFromPlaylistMutation,
|
||||||
|
useReorderPlaylistTracksMutation,
|
||||||
} = playlistsApi;
|
} = playlistsApi;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
|
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -34,6 +35,7 @@ export function AppShell() {
|
|||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
<TrackInfoDrawer />
|
<TrackInfoDrawer />
|
||||||
</div>
|
</div>
|
||||||
|
<AddToPlaylistDialog />
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
</div>
|
</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 {
|
interface Props {
|
||||||
track: Track;
|
track: Track;
|
||||||
onAddToPlaylist?: (track: Track) => void;
|
onAddToPlaylist?: (track: Track) => void;
|
||||||
|
onRemoveFromPlaylist?: (track: Track) => void;
|
||||||
onEditMetadata?: (track: Track) => void;
|
onEditMetadata?: (track: Track) => void;
|
||||||
onDelete?: (track: Track) => void;
|
onDelete?: (track: Track) => void;
|
||||||
onDownload?: (track: Track) => void;
|
onDownload?: (track: Track) => void;
|
||||||
@@ -24,6 +25,7 @@ interface Props {
|
|||||||
export function TrackContextMenu({
|
export function TrackContextMenu({
|
||||||
track,
|
track,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
|
onRemoveFromPlaylist,
|
||||||
onEditMetadata,
|
onEditMetadata,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDownload,
|
onDownload,
|
||||||
@@ -87,6 +89,11 @@ export function TrackContextMenu({
|
|||||||
{t('track.menu.addToPlaylist')}
|
{t('track.menu.addToPlaylist')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onRemoveFromPlaylist && (
|
||||||
|
<MenuItem onSelect={() => onRemoveFromPlaylist(track)}>
|
||||||
|
{t('track.menu.removeFromPlaylist')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{onEditMetadata && (
|
{onEditMetadata && (
|
||||||
<MenuItem onSelect={() => onEditMetadata(track)}>
|
<MenuItem onSelect={() => onEditMetadata(track)}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Props {
|
|||||||
* the header and per-track art would be redundant. */
|
* the header and per-track art would be redundant. */
|
||||||
hideArt?: boolean;
|
hideArt?: boolean;
|
||||||
onAddToPlaylist?: (track: Track) => void;
|
onAddToPlaylist?: (track: Track) => void;
|
||||||
|
onRemoveFromPlaylist?: (track: Track) => void;
|
||||||
onEditMetadata?: (track: Track) => void;
|
onEditMetadata?: (track: Track) => void;
|
||||||
onDelete?: (track: Track) => void;
|
onDelete?: (track: Track) => void;
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export function TrackRow({
|
|||||||
showAlbum = false,
|
showAlbum = false,
|
||||||
hideArt = false,
|
hideArt = false,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
|
onRemoveFromPlaylist,
|
||||||
onEditMetadata,
|
onEditMetadata,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -190,6 +192,7 @@ export function TrackRow({
|
|||||||
<TrackContextMenu
|
<TrackContextMenu
|
||||||
track={track}
|
track={track}
|
||||||
onAddToPlaylist={onAddToPlaylist}
|
onAddToPlaylist={onAddToPlaylist}
|
||||||
|
onRemoveFromPlaylist={onRemoveFromPlaylist}
|
||||||
onEditMetadata={onEditMetadata}
|
onEditMetadata={onEditMetadata}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
|||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
import {
|
import {
|
||||||
selectLocalAlbums,
|
selectLocalAlbums,
|
||||||
@@ -214,7 +215,13 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{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>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
|||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
import {
|
import {
|
||||||
selectLocalArtists,
|
selectLocalArtists,
|
||||||
@@ -241,7 +242,13 @@ export function ArtistDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{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>
|
</section>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
TextField,
|
TextField,
|
||||||
Callout,
|
Callout,
|
||||||
|
SearchField,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -31,6 +32,7 @@ import type { Track, Album, Artist } from '../../api/types';
|
|||||||
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from 'use-debounce';
|
||||||
|
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||||
|
|
||||||
/** Case-insensitive substring match used for client-side search while offline
|
/** 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). */
|
* (the server can't run the query, so we filter the locally-cached library). */
|
||||||
@@ -143,7 +145,8 @@ export function LibraryPage() {
|
|||||||
{t('library.title')}
|
{t('library.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||||
<TextField
|
<SearchField
|
||||||
|
icon={<MagnifyingGlassIcon />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t('library.searchPlaceholder')}
|
placeholder={t('library.searchPlaceholder')}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
import {
|
||||||
useGetPlaylistQuery,
|
useGetPlaylistQuery,
|
||||||
useGetPlaylistTracksQuery,
|
useGetPlaylistTracksQuery,
|
||||||
|
useUpdatePlaylistMutation,
|
||||||
|
useDeletePlaylistMutation,
|
||||||
|
useRemoveTrackFromPlaylistMutation,
|
||||||
} from '../../api/endpoints/playlists';
|
} from '../../api/endpoints/playlists';
|
||||||
import { TrackRow } from '../../components/track/TrackRow';
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
@@ -11,6 +22,7 @@ import { ErrorState } from '../../components/common/ErrorState';
|
|||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
|
import { openAddToPlaylist } from '../../store/slices/ui';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
|
|
||||||
export function PlaylistDetailPage() {
|
export function PlaylistDetailPage() {
|
||||||
@@ -25,6 +37,13 @@ export function PlaylistDetailPage() {
|
|||||||
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', {
|
const tracksQuery = useGetPlaylistTracksQuery(playlistId ?? '', {
|
||||||
skip: !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) {
|
if (playlistQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -124,6 +143,23 @@ export function PlaylistDetailPage() {
|
|||||||
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
|
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handlePlayAll}
|
onClick={handlePlayAll}
|
||||||
@@ -133,6 +169,63 @@ export function PlaylistDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
||||||
{tracksQuery.isError && (
|
{tracksQuery.isError && (
|
||||||
@@ -156,6 +249,11 @@ export function PlaylistDetailPage() {
|
|||||||
track={track}
|
track={track}
|
||||||
index={i}
|
index={i}
|
||||||
showAlbum
|
showAlbum
|
||||||
|
onAddToPlaylist={(tr) => dispatch(openAddToPlaylist(tr.id))}
|
||||||
|
onRemoveFromPlaylist={(tr) => {
|
||||||
|
if (!playlistId) return;
|
||||||
|
void removeTrackFromPlaylist({ playlistId, trackId: tr.id });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,8 +1,273 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export function PlaylistsPage() {
|
||||||
const { t } = useTranslation();
|
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 { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import type { ExternalSearchResult } from '../../api/types';
|
import type { ExternalSearchResult } from '../../api/types';
|
||||||
|
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
||||||
|
|
||||||
const ALL = '__all__';
|
const ALL = '__all__';
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ export function SearchDownloadPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<SearchField
|
<SearchField
|
||||||
icon={<Icon name="magnifying-glass" />}
|
icon={<MagnifyingGlassIcon />}
|
||||||
placeholder={t('discover.searchPlaceholder')}
|
placeholder={t('discover.searchPlaceholder')}
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge'
|
|||||||
import { TrackRow } from '../../components/track/TrackRow';
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
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
|
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||||
* it survives a page refresh — unlike the transient client-side queue above. */
|
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||||
@@ -53,6 +55,7 @@ function extractError(err: unknown): string {
|
|||||||
export function UploadPage() {
|
export function UploadPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const [uploadTrack] = useUploadTrackMutation();
|
const [uploadTrack] = useUploadTrackMutation();
|
||||||
|
|
||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
@@ -284,7 +287,13 @@ export function UploadPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{recentQuery.data?.items.map((track, i) => (
|
{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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ const en = {
|
|||||||
title: 'Empty playlist',
|
title: 'Empty playlist',
|
||||||
description: 'This playlist has no tracks yet.',
|
description: 'This playlist has no tracks yet.',
|
||||||
},
|
},
|
||||||
|
menu: {
|
||||||
|
rename: 'Rename',
|
||||||
|
delete: 'Delete',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Nothing playing',
|
nothingPlaying: 'Nothing playing',
|
||||||
@@ -192,11 +196,50 @@ const en = {
|
|||||||
addToQueue: 'Add to queue',
|
addToQueue: 'Add to queue',
|
||||||
info: 'Track info',
|
info: 'Track info',
|
||||||
addToPlaylist: 'Add to playlist…',
|
addToPlaylist: 'Add to playlist…',
|
||||||
|
removeFromPlaylist: 'Remove from playlist',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
delete: 'Delete',
|
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: {
|
trackInfo: {
|
||||||
title: 'Track info',
|
title: 'Track info',
|
||||||
open: 'View track info',
|
open: 'View track info',
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ const ru: Translations = {
|
|||||||
title: 'Плейлист пуст',
|
title: 'Плейлист пуст',
|
||||||
description: 'В этом плейлисте пока нет треков.',
|
description: 'В этом плейлисте пока нет треков.',
|
||||||
},
|
},
|
||||||
|
menu: {
|
||||||
|
rename: 'Переименовать',
|
||||||
|
delete: 'Удалить',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Ничего не играет',
|
nothingPlaying: 'Ничего не играет',
|
||||||
@@ -193,11 +197,50 @@ const ru: Translations = {
|
|||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
info: 'Информация о треке',
|
info: 'Информация о треке',
|
||||||
addToPlaylist: 'Добавить в плейлист…',
|
addToPlaylist: 'Добавить в плейлист…',
|
||||||
|
removeFromPlaylist: 'Убрать из плейлиста',
|
||||||
editMetadata: 'Редактировать метаданные',
|
editMetadata: 'Редактировать метаданные',
|
||||||
download: 'Скачать',
|
download: 'Скачать',
|
||||||
delete: 'Удалить',
|
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: {
|
trackInfo: {
|
||||||
title: 'О треке',
|
title: 'О треке',
|
||||||
open: 'Информация о треке',
|
open: 'Информация о треке',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface UiState {
|
|||||||
activeTrackContextMenuId: string | null;
|
activeTrackContextMenuId: string | null;
|
||||||
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||||
trackInfoId: string | null;
|
trackInfoId: string | null;
|
||||||
|
/** Track for which the "Add to playlist" dialog is open; null = closed. */
|
||||||
|
addToPlaylistTrackId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UiState = {
|
const initialState: UiState = {
|
||||||
@@ -13,6 +15,7 @@ const initialState: UiState = {
|
|||||||
activeModal: null,
|
activeModal: null,
|
||||||
activeTrackContextMenuId: null,
|
activeTrackContextMenuId: null,
|
||||||
trackInfoId: null,
|
trackInfoId: null,
|
||||||
|
addToPlaylistTrackId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uiSlice = createSlice({
|
export const uiSlice = createSlice({
|
||||||
@@ -40,6 +43,12 @@ export const uiSlice = createSlice({
|
|||||||
closeTrackInfo(state) {
|
closeTrackInfo(state) {
|
||||||
state.trackInfoId = null;
|
state.trackInfoId = null;
|
||||||
},
|
},
|
||||||
|
openAddToPlaylist(state, action: PayloadAction<string>) {
|
||||||
|
state.addToPlaylistTrackId = action.payload;
|
||||||
|
},
|
||||||
|
closeAddToPlaylist(state) {
|
||||||
|
state.addToPlaylistTrackId = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,5 +60,7 @@ export const {
|
|||||||
setActiveContextMenu,
|
setActiveContextMenu,
|
||||||
openTrackInfo,
|
openTrackInfo,
|
||||||
closeTrackInfo,
|
closeTrackInfo,
|
||||||
|
openAddToPlaylist,
|
||||||
|
closeAddToPlaylist,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
|||||||
Reference in New Issue
Block a user