diff --git a/src/api/endpoints/playlists.ts b/src/api/endpoints/playlists.ts index c1007af..b7a68b5 100644 --- a/src/api/endpoints/playlists.ts +++ b/src/api/endpoints/playlists.ts @@ -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; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index aef5ebc..c3c6c3c 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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() { + ); diff --git a/src/components/track/AddToPlaylistDialog.tsx b/src/components/track/AddToPlaylistDialog.tsx new file mode 100644 index 0000000..bd12bf6 --- /dev/null +++ b/src/components/track/AddToPlaylistDialog.tsx @@ -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 ( + { + if (!open) dispatch(closeAddToPlaylist()); + }} + title={t('addToPlaylist.title')} + > + {trackId ? : null} + + ); +} + +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(null); + const [error, setError] = useState(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 ( +
+ {addedTo ? ( + + {t('addToPlaylist.added', { name: addedTo })} + + ) : ( + <> + {error && {error}} + + {isLoading && } + {isError && } + {!isLoading && !isError && playlists && playlists.items.length === 0 && ( + + )} + + {!isLoading && !isError && playlists && playlists.items.length > 0 && ( +
+ {playlists.items.map((playlist) => ( + + ))} +
+ )} + + {creatingNew ? ( +
void handleCreateAndAdd(e)} + style={{ display: 'flex', gap: '0.5rem' }} + > + setNewName(e.target.value)} + placeholder={t('addToPlaylist.newPlaceholder')} + style={{ flex: 1 }} + /> + + + ) : ( + + )} + + )} +
+ ); +} diff --git a/src/components/track/TrackContextMenu.tsx b/src/components/track/TrackContextMenu.tsx index 1704fd5..cfc9bcb 100644 --- a/src/components/track/TrackContextMenu.tsx +++ b/src/components/track/TrackContextMenu.tsx @@ -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')} )} + {onRemoveFromPlaylist && ( + onRemoveFromPlaylist(track)}> + {t('track.menu.removeFromPlaylist')} + + )} {onEditMetadata && ( onEditMetadata(track)}> diff --git a/src/components/track/TrackRow.tsx b/src/components/track/TrackRow.tsx index f4d80a9..5d737ab 100644 --- a/src/components/track/TrackRow.tsx +++ b/src/components/track/TrackRow.tsx @@ -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({ diff --git a/src/features/album-detail/AlbumDetailPage.tsx b/src/features/album-detail/AlbumDetailPage.tsx index bb2390b..985a154 100644 --- a/src/features/album-detail/AlbumDetailPage.tsx +++ b/src/features/album-detail/AlbumDetailPage.tsx @@ -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) => ( - + dispatch(openAddToPlaylist(t.id))} + /> ))} diff --git a/src/features/artist-detail/ArtistDetailPage.tsx b/src/features/artist-detail/ArtistDetailPage.tsx index 7241c90..9421879 100644 --- a/src/features/artist-detail/ArtistDetailPage.tsx +++ b/src/features/artist-detail/ArtistDetailPage.tsx @@ -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) => ( - + dispatch(openAddToPlaylist(t.id))} + /> ))} diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index a4eeec8..5e7ca42 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -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')}
- } value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('library.searchPlaceholder')} diff --git a/src/features/playlist-detail/PlaylistDetailPage.tsx b/src/features/playlist-detail/PlaylistDetailPage.tsx index fe7f700..7e2b12d 100644 --- a/src/features/playlist-detail/PlaylistDetailPage.tsx +++ b/src/features/playlist-detail/PlaylistDetailPage.tsx @@ -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)}`}

+ {playlist && ( + <> + + + + )} + + + } + > + setRenameValue(e.target.value)} + /> + + + { + void deletePlaylist(playlist.id); + void navigate('/playlists'); + }} + /> + + )} + {tracksQuery.isLoading && } {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 }); + }} /> ))} diff --git a/src/features/playlists/PlaylistsPage.tsx b/src/features/playlists/PlaylistsPage.tsx index 146c9f1..eef4568 100644 --- a/src/features/playlists/PlaylistsPage.tsx +++ b/src/features/playlists/PlaylistsPage.tsx @@ -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 ; + 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 ( +
+
+

+ {t('nav.playlists')} +

+ { + setCreateOpen(open); + if (!open) setNewName(''); + }} + title={t('playlists.newDialog.title')} + trigger={ + + } + footer={ +
+ + +
+ } + > +
void handleCreate(e)}> + setNewName(e.target.value)} + placeholder={t('playlists.newDialog.placeholder')} + /> + +
+
+ +
+ {isLoading && } + {isError && } + {!isLoading && !isError && playlists.length === 0 && ( + setCreateOpen(true)}> + {t('nav.newPlaylist')} + + } + /> + )} + {!isLoading && !isError && playlists.length > 0 && ( +
+ {playlists.map((playlist) => ( + void navigate(`/playlists/${playlist.id}`)} + /> + ))} +
+ )} +
+
+ ); +} + +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 ( + +
+ + + + + + ⋯ + + + + { + setRenameValue(playlist.name); + setRenameOpen(true); + }} + > + {t('playlists.menu.rename')} + + setDeleteOpen(true)}> + {t('playlists.menu.delete')} + + + +
+ + + + + + } + > +
void handleRename(e)}> + setRenameValue(e.target.value)} + /> + +
+ + void deletePlaylist(playlist.id)} + /> +
+ ); } diff --git a/src/features/search-download/SearchDownloadPage.tsx b/src/features/search-download/SearchDownloadPage.tsx index 7a44ad8..9cd1138 100644 --- a/src/features/search-download/SearchDownloadPage.tsx +++ b/src/features/search-download/SearchDownloadPage.tsx @@ -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() { >
} + icon={} placeholder={t('discover.searchPlaceholder')} value={term} onChange={(e) => setTerm(e.target.value)} diff --git a/src/features/upload/UploadPage.tsx b/src/features/upload/UploadPage.tsx index 545ca1c..b00038f 100644 --- a/src/features/upload/UploadPage.tsx +++ b/src/features/upload/UploadPage.tsx @@ -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([]); @@ -284,7 +287,13 @@ export function UploadPage() {

)} {recentQuery.data?.items.map((track, i) => ( - + dispatch(openAddToPlaylist(t.id))} + /> ))}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 15b023b..9d7d17b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 956c73d..281a11c 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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: 'Информация о треке', diff --git a/src/store/slices/ui.ts b/src/store/slices/ui.ts index 0c994c0..04110a8 100644 --- a/src/store/slices/ui.ts +++ b/src/store/slices/ui.ts @@ -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) { + 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;