stuff
Docker Build & Publish / build (push) Successful in 35s
Docker Build & Publish / push (push) Failing after 2s
Docker Build & Publish / Prune old image versions (push) Has been skipped

This commit is contained in:
Senko-san
2026-07-23 12:02:13 +03:00
parent 608b85a630
commit 205b51ec73
15 changed files with 704 additions and 9 deletions
+14
View File
@@ -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;
+2
View File
@@ -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)}>
+3
View File
@@ -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>
+4 -1
View File
@@ -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>
+268 -3
View File
@@ -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)}
+10 -1
View File
@@ -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>
+43
View File
@@ -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',
+43
View File
@@ -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: 'Информация о треке',
+11
View File
@@ -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;