diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 94d8d78f..d768e306 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Play, HardDriveDownload } from 'lucide-react'; +import { Play, HardDriveDownload, Check } from 'lucide-react'; import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; @@ -11,9 +11,12 @@ import { useDragDrop } from '../contexts/DragDropContext'; interface AlbumCardProps { album: SubsonicAlbum; + selected?: boolean; + selectionMode?: boolean; + onToggleSelect?: (id: string) => void; } -function AlbumCard({ album }: AlbumCardProps) { +function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCardProps) { const navigate = useNavigate(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const serverId = useAuthStore(s => s.activeServerId ?? ''); @@ -25,20 +28,26 @@ function AlbumCard({ album }: AlbumCardProps) { const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; const psyDrag = useDragDrop(); + const handleClick = () => { + if (selectionMode) { onToggleSelect?.(album.id); return; } + navigate(`/album/${album.id}`); + }; + return (
navigate(`/album/${album.id}`)} + className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`} + onClick={handleClick} role="button" tabIndex={0} aria-label={`${album.name} von ${album.artist}`} - onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)} + onKeyDown={e => e.key === 'Enter' && handleClick()} onContextMenu={(e) => { + if (selectionMode) { e.preventDefault(); return; } e.preventDefault(); openContextMenu(e.clientX, e.clientY, album, 'album'); }} onMouseDown={e => { - if (e.button !== 0) return; + if (selectionMode || e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { @@ -64,20 +73,27 @@ function AlbumCard({ album }: AlbumCardProps) {
)} - {isOffline && ( + {isOffline && !selectionMode && (
)} -
- -
+ {selectionMode && ( +
+ {selected && } +
+ )} + {!selectionMode && ( +
+ +
+ )}

{album.name}

diff --git a/src/locales/de.ts b/src/locales/de.ts index 9a74d3b3..28ee7194 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -244,6 +244,17 @@ export const deTranslation = { yearTo: 'Bis', yearFilterClear: 'Jahresfilter zurücksetzen', yearFilterLabel: 'Jahr', + select: 'Mehrfachauswahl', + startSelect: 'Mehrfachauswahl aktivieren', + cancelSelect: 'Abbrechen', + selectionCount: '{{count}} ausgewählt', + downloadZips: 'ZIPs herunterladen', + addOffline: 'Offline hinzufügen', + downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}', + downloadZipDone: '{{count}} ZIP(s) heruntergeladen', + downloadZipFailed: 'Download fehlgeschlagen: {{name}}', + offlineQueuing: '{{count}} Album(s) für Offline einreihen…', + offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden', }, artists: { title: 'Künstler', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5b1fa647..220d11e4 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -245,6 +245,17 @@ export const enTranslation = { yearTo: 'To', yearFilterClear: 'Clear year filter', yearFilterLabel: 'Year', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: 'Artists', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index a596e643..efab16e6 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -244,6 +244,17 @@ export const frTranslation = { yearTo: 'À', yearFilterClear: 'Effacer le filtre année', yearFilterLabel: 'Année', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: 'Artistes', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index a5f8cf5e..11c2ce01 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -243,7 +243,18 @@ export const nbTranslation = { yearFrom: 'Fra', yearTo: 'Til', yearFilterClear: 'Tøm år filteret', - yearFilterLabel: 'År', + yearFilterLabel: 'År', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: 'Artister', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 5323109b..099a8e43 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -244,6 +244,17 @@ export const nlTranslation = { yearTo: 'Tot', yearFilterClear: 'Jaarfilter wissen', yearFilterLabel: 'Jaar', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: 'Artiesten', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 33c12fc9..ebf6ded5 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -253,6 +253,17 @@ export const ruTranslation = { yearTo: 'По', yearFilterClear: 'Сбросить год', yearFilterLabel: 'Год', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: 'Исполнители', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 134ded62..3c8e6785 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -244,6 +244,17 @@ export const zhTranslation = { yearTo: '到', yearFilterClear: '清除年份筛选', yearFilterLabel: '年份', + select: 'Multi-select', + startSelect: 'Enable multi-select', + cancelSelect: 'Cancel', + selectionCount: '{{count}} selected', + downloadZips: 'Download ZIPs', + addOffline: 'Add Offline', + downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', + downloadZipDone: '{{count}} ZIP(s) downloaded', + downloadZipFailed: 'Failed to download {{name}}', + offlineQueuing: 'Queuing {{count}} album(s) for offline…', + offlineFailed: 'Failed to add {{name}} offline', }, artists: { title: '艺术家', diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index dcb920ef..d04afb0d 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -1,16 +1,25 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; -import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; +import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; -import { X } from 'lucide-react'; +import { useOfflineStore } from '../store/offlineStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; +import { writeFile } from '@tauri-apps/plugin-fs'; +import { join } from '@tauri-apps/api/path'; +import { showToast } from '../utils/toast'; +import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; const PAGE_SIZE = 30; const CURRENT_YEAR = new Date().getFullYear(); +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; +} + async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const seen = new Set(); @@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise { export default function Albums() { const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const auth = useAuthStore(); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const { downloadAlbum } = useOfflineStore(); + const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); + const [albums, setAlbums] = useState([]); const [sort, setSort] = useState('alphabeticalByName'); const [loading, setLoading] = useState(true); @@ -30,6 +44,73 @@ export default function Albums() { const [yearTo, setYearTo] = useState(''); const observerTarget = useRef(null); + // ── Multi-selection ────────────────────────────────────────────────────── + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleSelectionMode = () => { + setSelectionMode(v => !v); + setSelectedIds(new Set()); + }; + + const toggleSelect = useCallback((id: string) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const clearSelection = () => { + setSelectionMode(false); + setSelectedIds(new Set()); + }; + + const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); + + const handleDownloadZips = async () => { + if (selectedAlbums.length === 0) return; + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; + + let done = 0; + for (const album of selectedAlbums) { + showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); + try { + const url = buildDownloadUrl(album.id); + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const buffer = await blob.arrayBuffer(); + const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); + await writeFile(path, new Uint8Array(buffer)); + done++; + } catch (e) { + console.error('ZIP download failed for', album.name, e); + showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); + } + } + showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); + clearSelection(); + }; + + const handleAddOffline = async () => { + if (selectedAlbums.length === 0) return; + let queued = 0; + for (const album of selectedAlbums) { + try { + const detail = await getAlbum(album.id); + downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + queued++; + } catch { + showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); + } + } + if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info'); + clearSelection(); + }; + + // ── Data loading ───────────────────────────────────────────────────────── const genreFiltered = selectedGenres.length > 0; const fromNum = parseInt(yearFrom, 10); const toNum = parseInt(yearTo, 10); @@ -108,58 +189,87 @@ export default function Albums() { return (
-

{t('albums.title')}

+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('albums.title')} +

- {!yearActive && sortOptions.map(o => ( - - ))} - - {/* Year range filter */} -
- - {t('albums.yearFilterLabel')} - - setYearFrom(e.target.value)} - style={{ width: 68, padding: '4px 6px', fontSize: 12 }} - /> - - setYearTo(e.target.value)} - style={{ width: 68, padding: '4px 6px', fontSize: 12 }} - /> - {yearActive && ( - - )} -
+ + + ) : ( + <> + {!yearActive && sortOptions.map(o => ( + + ))} - +
+ + {t('albums.yearFilterLabel')} + + setYearFrom(e.target.value)} + style={{ width: 68, padding: '4px 6px', fontSize: 12 }} + /> + + setYearTo(e.target.value)} + style={{ width: 68, padding: '4px 6px', fontSize: 12 }} + /> + {yearActive && ( + + )} +
+ + + + )} + +
@@ -170,7 +280,15 @@ export default function Albums() { ) : ( <>
- {albums.map(a => )} + {albums.map(a => ( + + ))}
{!genreFiltered && (
@@ -179,6 +297,7 @@ export default function Albums() { )} )} +
); } diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index e896a54c..44ce8dde 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -1,12 +1,22 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; -import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; +import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; +import { useOfflineStore } from '../store/offlineStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; +import { writeFile } from '@tauri-apps/plugin-fs'; +import { join } from '@tauri-apps/api/path'; +import { showToast } from '../utils/toast'; const PAGE_SIZE = 30; +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; +} + async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const seen = new Set(); @@ -17,6 +27,11 @@ async function fetchByGenres(genres: string[]): Promise { export default function NewReleases() { const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const auth = useAuthStore(); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const { downloadAlbum } = useOfflineStore(); + const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); + const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); @@ -25,6 +40,53 @@ export default function NewReleases() { const observerTarget = useRef(null); const filtered = selectedGenres.length > 0; + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; + const toggleSelect = useCallback((id: string) => { + setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + }, []); + const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; + const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); + + const handleDownloadZips = async () => { + if (selectedAlbums.length === 0) return; + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; + let done = 0; + for (const album of selectedAlbums) { + showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); + try { + const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }); + const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); + await writeFile(path, new Uint8Array(await blob.arrayBuffer())); + done++; + } catch (e) { + console.error('ZIP download failed for', album.name, e); + showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); + } + } + showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); + clearSelection(); + }; + + const handleAddOffline = async () => { + if (selectedAlbums.length === 0) return; + let queued = 0; + for (const album of selectedAlbums) { + try { + const detail = await getAlbum(album.id); + downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + queued++; + } catch { + showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); + } + } + if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info'); + clearSelection(); + }; + const load = useCallback(async (offset: number, append = false) => { setLoading(true); try { @@ -71,8 +133,37 @@ export default function NewReleases() { return (
-

{t('sidebar.newReleases')}

- +

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('sidebar.newReleases')} +

+
+ {selectionMode && selectedIds.size > 0 ? ( + <> + + + + ) : ( + + )} + +
{loading && albums.length === 0 ? ( @@ -82,7 +173,15 @@ export default function NewReleases() { ) : ( <>
- {albums.map(a => )} + {albums.map(a => ( + + ))}
{!filtered && (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index bb939c9f..9404c444 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -1,18 +1,26 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { RefreshCw } from 'lucide-react'; -import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic'; +import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; +import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; +import { useOfflineStore } from '../store/offlineStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; +import { writeFile } from '@tauri-apps/plugin-fs'; +import { join } from '@tauri-apps/api/path'; +import { showToast } from '../utils/toast'; const ALBUM_COUNT = 30; +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; +} + async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const seen = new Set(); const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }); - // Fisher-Yates shuffle for (let i = union.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [union[i], union[j]] = [union[j], union[i]]; @@ -23,20 +31,70 @@ async function fetchByGenres(genres: string[]): Promise { export default function RandomAlbums() { const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const auth = useAuthStore(); + const serverId = useAuthStore(s => s.activeServerId ?? ''); + const { downloadAlbum } = useOfflineStore(); + const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); + const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); const loadingRef = useRef(false); const filtered = selectedGenres.length > 0; + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; + const toggleSelect = useCallback((id: string) => { + setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + }, []); + const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; + const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); + + const handleDownloadZips = async () => { + if (selectedAlbums.length === 0) return; + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; + let done = 0; + for (const album of selectedAlbums) { + showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); + try { + const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }); + const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); + await writeFile(path, new Uint8Array(await blob.arrayBuffer())); + done++; + } catch (e) { + console.error('ZIP download failed for', album.name, e); + showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); + } + } + showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); + clearSelection(); + }; + + const handleAddOffline = async () => { + if (selectedAlbums.length === 0) return; + let queued = 0; + for (const album of selectedAlbums) { + try { + const detail = await getAlbum(album.id); + downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + queued++; + } catch { + showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); + } + } + if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info'); + clearSelection(); + }; + const load = useCallback(async (genres: string[]) => { if (loadingRef.current) return; loadingRef.current = true; setLoading(true); try { - const data = genres.length > 0 - ? await fetchByGenres(genres) - : await getAlbumList('random', ALBUM_COUNT); + const data = genres.length > 0 ? await fetchByGenres(genres) : await getAlbumList('random', ALBUM_COUNT); setAlbums(data); } catch (e) { console.error(e); @@ -51,17 +109,46 @@ export default function RandomAlbums() { return (
-

{t('randomAlbums.title')}

+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('randomAlbums.title')} +

- + {selectionMode && selectedIds.size > 0 ? ( + <> + + + + ) : ( + <> + + + + )}
@@ -72,7 +159,15 @@ export default function RandomAlbums() {
) : (
- {albums.map(a => )} + {albums.map(a => ( + + ))}
)}
diff --git a/src/styles/components.css b/src/styles/components.css index ef435a71..bb021889 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -496,6 +496,76 @@ background: var(--accent); } +/* ── Album card selection ── */ +.album-card--selectable { + cursor: pointer; +} + +.album-card--selected { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.album-card-select-check { + position: absolute; + top: 6px; + left: 6px; + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + border: 2px solid rgba(255,255,255,0.7); + background: rgba(0,0,0,0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 3; + pointer-events: none; +} + +.album-card-select-check--on { + background: var(--accent); + border-color: var(--accent); + color: var(--ctp-crust); +} + +/* ── Albums selection bar (portal) ── */ +.albums-selection-bar { + position: fixed; + bottom: calc(var(--player-height) + 12px); + left: 50%; + transform: translateX(-50%); + z-index: 9000; + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 8px 14px; + box-shadow: var(--shadow-lg); + white-space: nowrap; +} + +.albums-selection-count { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + padding-right: 4px; + border-right: 1px solid var(--border-subtle); +} + +.albums-selection-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.albums-selection-action-btn { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; +} .album-card-info { padding: var(--space-3) var(--space-4) var(--space-4);