mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: add multi-selection and context menu for playlist management
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,10 @@ interface AlbumCardProps {
|
||||
selectionMode?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
}
|
||||
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
|
||||
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
@@ -44,7 +45,11 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
if (selectionMode && selectedAlbums.length > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (selectionMode || e.button !== 0) return;
|
||||
|
||||
@@ -198,6 +198,733 @@ function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: st
|
||||
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
||||
}
|
||||
|
||||
// Resolves all songs from multiple albums and adds them to playlist with detailed toast notifications
|
||||
function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds: string[]; onDone: () => void; triggerId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalAlbums(albumIds.length);
|
||||
(async () => {
|
||||
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
|
||||
const allSongs = albumSongs.flat();
|
||||
setResolvedIds(allSongs.map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
}, [albumIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
const duplicateIds: string[] = [];
|
||||
|
||||
for (const id of songIds) {
|
||||
if (existingIds.has(id)) {
|
||||
duplicateIds.push(id);
|
||||
} else {
|
||||
newIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
}
|
||||
|
||||
// Show detailed toast notification
|
||||
const totalSongs = songIds.length;
|
||||
const addedCount = newIds.length;
|
||||
const duplicateCount = duplicateIds.length;
|
||||
|
||||
if (addedCount === 0 && duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('playlists.addError'), 4000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
const handleCreateWithToast = async (songIds: string[]) => {
|
||||
const { createPlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const name = t('playlists.unnamed');
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) {
|
||||
touchPlaylist(pl.id);
|
||||
showToast(
|
||||
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 4000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
// Custom AddToPlaylistSubmenu with toast notifications for multiple albums
|
||||
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [flipLeft, setFlipLeft] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists().then((all) => {
|
||||
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (subRef.current) {
|
||||
const rect = subRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) newNameRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||
setAdding(pl.id);
|
||||
await handleAddWithToast(pl, songIds);
|
||||
setAdding(null);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
const { createPlaylist } = await import('../api/subsonic');
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) {
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||
showToast(
|
||||
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 4000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
{playlists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||
)}
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAdd(pl)}
|
||||
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedIds === null) {
|
||||
return (
|
||||
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('playlists.loadingAlbums', { count: totalAlbums })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolvedIds.length === 0) return null;
|
||||
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||
}
|
||||
|
||||
// Resolves all songs from multiple artists and adds them to playlist with detailed toast notifications
|
||||
function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artistIds: string[]; onDone: () => void; triggerId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
const [totalArtists, setTotalArtists] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalArtists(artistIds.length);
|
||||
(async () => {
|
||||
const allSongs: string[] = [];
|
||||
for (const artistId of artistIds) {
|
||||
try {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
||||
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||
} catch {
|
||||
// Skip failed artists
|
||||
}
|
||||
}
|
||||
setResolvedIds(allSongs);
|
||||
})().catch(() => setResolvedIds([]));
|
||||
}, [artistIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
const duplicateIds: string[] = [];
|
||||
|
||||
for (const id of songIds) {
|
||||
if (existingIds.has(id)) {
|
||||
duplicateIds.push(id);
|
||||
} else {
|
||||
newIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0) {
|
||||
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
|
||||
touchPlaylist(pl.id);
|
||||
}
|
||||
|
||||
// Show detailed toast notification
|
||||
const addedCount = newIds.length;
|
||||
const duplicateCount = duplicateIds.length;
|
||||
|
||||
if (addedCount === 0 && duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else if (duplicateCount > 0) {
|
||||
showToast(
|
||||
t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }),
|
||||
4000,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
t('playlists.addSuccess', { count: addedCount, playlist: pl.name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(t('playlists.addError'), 4000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
// Custom AddToPlaylistSubmenu with toast notifications for multiple artists
|
||||
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [flipLeft, setFlipLeft] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists().then((all) => {
|
||||
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (subRef.current) {
|
||||
const rect = subRef.current.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) newNameRef.current?.focus();
|
||||
}, [creating]);
|
||||
|
||||
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||
setAdding(pl.id);
|
||||
await handleAddWithToast(pl, songIds);
|
||||
setAdding(null);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim() || t('playlists.unnamed');
|
||||
try {
|
||||
const { createPlaylist } = await import('../api/subsonic');
|
||||
const pl = await createPlaylist(name, songIds);
|
||||
if (pl?.id) {
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
usePlaylistStore.getState().touchPlaylist(pl.id);
|
||||
showToast(
|
||||
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
|
||||
3000,
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 4000, 'error');
|
||||
}
|
||||
onDone();
|
||||
};
|
||||
|
||||
const subStyle: React.CSSProperties = flipLeft
|
||||
? { right: 'calc(100% + 4px)', left: 'auto' }
|
||||
: { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
{playlists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||
)}
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAdd(pl)}
|
||||
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedIds === null) {
|
||||
return (
|
||||
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('playlists.loadingArtists', { count: totalArtists })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolvedIds.length === 0) return null;
|
||||
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||
}
|
||||
|
||||
// Submenu for adding a single playlist to another playlist
|
||||
function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { playlist: { id: string; name: string }; onDone: () => void; triggerId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { getPlaylists } = await import('../api/subsonic');
|
||||
const pls = await getPlaylists().catch(() => []);
|
||||
setAllPlaylists(pls.filter((p: { id: string }) => p.id !== playlist.id));
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [playlist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating && newNameRef.current) {
|
||||
newNameRef.current.focus();
|
||||
}
|
||||
}, [creating]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const { createPlaylist } = await import('../api/subsonic');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
try {
|
||||
const newPl = await createPlaylist(newName.trim(), []);
|
||||
if (newPl?.id) {
|
||||
await handleAddToNewPlaylist(newPl.id, newPl.name || newName.trim());
|
||||
}
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 3000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
||||
if (sourceSongs.length > 0) {
|
||||
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
|
||||
touchPlaylist(targetId);
|
||||
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
|
||||
}
|
||||
onDone();
|
||||
} catch {
|
||||
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (targetId: string, targetName: string) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: targetSongs } = await getPlaylist(targetId);
|
||||
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
||||
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
||||
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||
|
||||
if (newSongs.length > 0) {
|
||||
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||
await updatePlaylist(targetId, Array.from(targetIds));
|
||||
touchPlaylist(targetId);
|
||||
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
|
||||
} else {
|
||||
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
|
||||
}
|
||||
onDone();
|
||||
} catch {
|
||||
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
|
||||
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||
{/* New Playlist row */}
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
{allPlaylists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||
)}
|
||||
{allPlaylists.map(pl => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleAdd(pl.id, pl.name)}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Submenu for merging multiple playlists into another playlist
|
||||
function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { playlists: { id: string; name: string }[]; onDone: () => void; triggerId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const subRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const newNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { getPlaylists } = await import('../api/subsonic');
|
||||
const pls = await getPlaylists().catch(() => []);
|
||||
const selectedIds = new Set(playlists.map(p => p.id));
|
||||
setAllPlaylists(pls.filter((p: { id: string }) => !selectedIds.has(p.id)));
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [playlists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating && newNameRef.current) {
|
||||
newNameRef.current.focus();
|
||||
}
|
||||
}, [creating]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const { createPlaylist } = await import('../api/subsonic');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
try {
|
||||
const newPl = await createPlaylist(newName.trim(), []);
|
||||
if (newPl?.id) {
|
||||
await handleMergeToNewPlaylist(newPl.id, newPl.name || newName.trim());
|
||||
}
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
} catch {
|
||||
showToast(t('playlists.createError'), 3000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const targetIds = new Set<string>();
|
||||
let totalAdded = 0;
|
||||
|
||||
for (const pl of playlists) {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||
if (newSongs.length > 0) {
|
||||
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||
totalAdded += newSongs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
await updatePlaylist(targetId, Array.from(targetIds));
|
||||
touchPlaylist(targetId);
|
||||
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
||||
}
|
||||
onDone();
|
||||
} catch {
|
||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async (targetId: string, targetName: string) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: targetSongs } = await getPlaylist(targetId);
|
||||
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
||||
let totalAdded = 0;
|
||||
|
||||
for (const pl of playlists) {
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
|
||||
if (newSongs.length > 0) {
|
||||
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
||||
totalAdded += newSongs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
await updatePlaylist(targetId, Array.from(targetIds));
|
||||
touchPlaylist(targetId);
|
||||
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
||||
} else {
|
||||
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||
}
|
||||
onDone();
|
||||
} catch {
|
||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||
onDone();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
|
||||
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
|
||||
|
||||
return (
|
||||
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||
{/* New Playlist row */}
|
||||
{!creating ? (
|
||||
<div
|
||||
className="context-menu-item context-submenu-new"
|
||||
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||
>
|
||||
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
ref={newNameRef}
|
||||
className="context-submenu-input"
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="context-menu-divider" />
|
||||
|
||||
{allPlaylists.length === 0 && (
|
||||
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||
)}
|
||||
{allPlaylists.map(pl => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="context-menu-item"
|
||||
onClick={() => handleMerge(pl.id, pl.name)}
|
||||
>
|
||||
<ListMusic size={13} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContextMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
@@ -820,6 +1547,72 @@ export default function ContextMenu() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'playlist' && (() => {
|
||||
const playlist = item as SubsonicPlaylist;
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/playlists/${playlist.id}`))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const { deletePlaylist } = await import('../api/subsonic');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const removeId = usePlaylistStore.getState().removeId;
|
||||
try {
|
||||
await deletePlaylist(playlist.id);
|
||||
removeId(playlist.id);
|
||||
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
|
||||
window.location.reload();
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: playlist.name }), 3000, 'error');
|
||||
}
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'multi-album' && (() => {
|
||||
const albums = item as SubsonicAlbum[];
|
||||
const albumIds = albums.map(a => a.id);
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
{t('contextMenu.selectedAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'artist' && (() => {
|
||||
const artist = item as SubsonicArtist;
|
||||
const artistRatingDisabled = entityRatingSupport === 'track_only';
|
||||
@@ -870,6 +1663,78 @@ export default function ContextMenu() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'multi-artist' && (() => {
|
||||
const artists = item as SubsonicArtist[];
|
||||
const artistIds = artists.map(a => a.id);
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'multi-playlist' && (() => {
|
||||
const selectedPlaylists = item as SubsonicPlaylist[];
|
||||
const playlistIds = selectedPlaylists.map(p => p.id);
|
||||
return (
|
||||
<>
|
||||
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../utils/toast');
|
||||
const { usePlaylistStore } = await import('../store/playlistStore');
|
||||
const { deletePlaylist } = await import('../api/subsonic');
|
||||
const removeId = usePlaylistStore.getState().removeId;
|
||||
let deleted = 0;
|
||||
for (const pl of selectedPlaylists) {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
deleted++;
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||
}
|
||||
window.location.reload();
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === 'queue-item' && (() => {
|
||||
const song = item as Track;
|
||||
return (
|
||||
|
||||
+78
-12
@@ -10,10 +10,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
import { getPlaylists, SubsonicPlaylist } from '../api/subsonic';
|
||||
|
||||
// All configurable nav items — order and visibility controlled by sidebarStore.
|
||||
// Exported so Settings can render the same item metadata.
|
||||
@@ -56,6 +57,9 @@ export default function Sidebar({
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
|
||||
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
|
||||
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
@@ -113,6 +117,16 @@ export default function Sidebar({
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
setPlaylistsLoading(true);
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => {})
|
||||
.finally(() => setPlaylistsLoading(false));
|
||||
}, [playlistsExpanded, isLoggedIn]);
|
||||
|
||||
// Resolve ordered, visible items per section from store config
|
||||
const visibleLibrary = sidebarItems
|
||||
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
|
||||
@@ -216,17 +230,69 @@ export default function Sidebar({
|
||||
<span className="nav-section-label">{t('sidebar.library')}</span>
|
||||
))}
|
||||
{visibleLibrary.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
item.to === '/playlists' ? (
|
||||
// Playlists item with expand button
|
||||
<div key={item.to} className="sidebar-playlists-wrapper">
|
||||
<div className="sidebar-playlists-header-row">
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
|
||||
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
|
||||
aria-expanded={playlistsExpanded}
|
||||
aria-label={playlistsExpanded ? 'Colapsar listas' : 'Expandir listas'}
|
||||
title={playlistsExpanded ? 'Colapsar listas' : 'Expandir listas'}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && playlistsExpanded && (
|
||||
<div className="sidebar-playlists-list">
|
||||
{playlistsLoading ? (
|
||||
<div className="sidebar-playlists-loading">
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
|
||||
) : (
|
||||
playlists.map(pl => (
|
||||
<NavLink
|
||||
key={pl.id}
|
||||
to={`/playlists/${pl.id}`}
|
||||
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? pl.name : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<PlayCircle size={12} />
|
||||
<span>{pl.name}</span>
|
||||
</NavLink>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<item.icon size={isCollapsed ? 22 : 18} />
|
||||
{!isCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Now Playing — fixed, always visible */}
|
||||
|
||||
@@ -110,6 +110,9 @@ export const esTranslation = {
|
||||
goToArtist: 'Ir al Artista',
|
||||
download: 'Descargar (ZIP)',
|
||||
addToPlaylist: 'Agregar a Lista de Reproducción',
|
||||
selectedPlaylists: '{{count}} listas seleccionadas',
|
||||
selectedAlbums: '{{count}} álbumes seleccionados',
|
||||
selectedArtists: '{{count}} artistas seleccionados',
|
||||
songInfo: 'Información de la Canción',
|
||||
},
|
||||
albumDetail: {
|
||||
@@ -276,6 +279,7 @@ export const esTranslation = {
|
||||
downloadZipFailed: 'Error al descargar {{name}}',
|
||||
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
|
||||
offlineFailed: 'Error al agregar {{name}} offline',
|
||||
addToPlaylist: 'Agregar a Lista',
|
||||
},
|
||||
artists: {
|
||||
title: 'Artistas',
|
||||
@@ -289,6 +293,11 @@ export const esTranslation = {
|
||||
notFound: 'No se encontraron artistas.',
|
||||
albumCount_one: '{{count}} Álbum',
|
||||
albumCount_other: '{{count}} Álbumes',
|
||||
selectionCount: '{{count}} seleccionados',
|
||||
select: 'Seleccionar',
|
||||
startSelect: 'Activar selección',
|
||||
cancelSelect: 'Cancelar',
|
||||
addToPlaylist: 'Agregar a Lista',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'Tu Reproductor Navidrome para Escritorio',
|
||||
@@ -910,6 +919,29 @@ export const esTranslation = {
|
||||
coverUpdated: 'Portada actualizada',
|
||||
metaSaved: 'Lista actualizada',
|
||||
downloadZip: 'Descargar (ZIP)',
|
||||
// Toast notifications for multi-select
|
||||
addSuccess: '{{count}} canciones agregadas a {{playlist}}',
|
||||
addPartial: '{{added}} agregadas, {{skipped}} skippeadas por duplicadas',
|
||||
addAllSkipped: 'Todas skippeadas ({{count}} duplicadas)',
|
||||
addError: 'Error al agregar canciones',
|
||||
createAndAddSuccess: 'Lista "{{playlist}}" creada con {{count}} canciones',
|
||||
createError: 'Error al crear lista',
|
||||
deleteSuccess: '{{count}} listas eliminadas',
|
||||
deleteFailed: 'Error al eliminar {{name}}',
|
||||
deleteSelected: 'Eliminar seleccionadas',
|
||||
mergeSuccess: '{{count}} canciones unificadas en {{playlist}}',
|
||||
mergeNoNewSongs: 'No hay canciones nuevas para agregar',
|
||||
mergeError: 'Error al unificar listas',
|
||||
mergeInto: 'Unificar en',
|
||||
selectionCount: '{{count}} seleccionadas',
|
||||
select: 'Seleccionar',
|
||||
startSelect: 'Activar selección',
|
||||
cancelSelect: 'Cancelar',
|
||||
myPlaylists: 'Mis Listas',
|
||||
noOtherPlaylists: 'No hay otras listas disponibles',
|
||||
addToPlaylistSuccess: '{{count}} canciones agregadas a {{playlist}}',
|
||||
addToPlaylistNoNew: 'No hay canciones nuevas para agregar a {{playlist}}',
|
||||
addToPlaylistError: 'Error al agregar a la lista',
|
||||
},
|
||||
mostPlayed: {
|
||||
title: 'Más Reproducidos',
|
||||
|
||||
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
|
||||
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
|
||||
|
||||
@@ -68,6 +69,7 @@ export default function Albums() {
|
||||
};
|
||||
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -284,6 +286,7 @@ export default function Albums() {
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+131
-42
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
|
||||
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -81,25 +81,55 @@ export default function Artists() {
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(50);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 selectedArtists = artists.filter(a => selectedIds.has(a.id));
|
||||
|
||||
useEffect(() => {
|
||||
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleCount(prev => prev + 50);
|
||||
}, []);
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setVisibleCount(prev => prev + PAGE_SIZE);
|
||||
setTimeout(() => setLoadingMore(false), 100);
|
||||
}, [loadingMore, PAGE_SIZE]);
|
||||
|
||||
// Reset infinite scroll when filters change
|
||||
// Reset infinite scroll when filters or image setting change
|
||||
useEffect(() => {
|
||||
setVisibleCount(50);
|
||||
}, [filter, letterFilter, viewMode]);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
||||
|
||||
// Filter pipeline
|
||||
let filtered = artists;
|
||||
@@ -120,6 +150,16 @@ export default function Artists() {
|
||||
const visible = filtered.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
// Intersection Observer for infinite scroll (after hasMore declaration)
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
if (observerTarget.current) observer.observe(observerTarget.current);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore]);
|
||||
|
||||
// Group by first letter (for list view)
|
||||
const groups: Record<string, SubsonicArtist[]> = {};
|
||||
visible.forEach(a => {
|
||||
@@ -134,7 +174,11 @@ export default function Artists() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('artists.selectionCount', { count: selectedIds.size })
|
||||
: t('artists.title')}
|
||||
</h1>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
@@ -146,30 +190,43 @@ export default function Artists() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
>
|
||||
<List size={20} />
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,13 +250,33 @@ export default function Artists() {
|
||||
{visible.map(artist => (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
navigate(`/artist/${artist.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : {}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
@@ -221,13 +298,27 @@ export default function Artists() {
|
||||
{groups[letter].map(artist => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="artist-row"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
navigate(`/artist/${artist.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
background: 'var(--accent-dim)',
|
||||
color: 'var(--accent)'
|
||||
} : {}}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
@@ -245,10 +336,8 @@ export default function Artists() {
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={loadMore}>
|
||||
<ChevronDown size={16} /> {t('artists.loadMore')}
|
||||
</button>
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { showToast } from '../utils/toast';
|
||||
@@ -50,6 +51,7 @@ export default function NewReleases() {
|
||||
}, []);
|
||||
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
|
||||
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
|
||||
const handleDownloadZips = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
@@ -183,6 +185,7 @@ export default function NewReleases() {
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+146
-39
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
|
||||
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
@@ -16,6 +17,7 @@ export default function Playlists() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
|
||||
const removeId = usePlaylistStore((s) => s.removeId);
|
||||
|
||||
@@ -27,6 +29,30 @@ export default function Playlists() {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
|
||||
|
||||
useEffect(() => {
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
@@ -78,6 +104,55 @@ export default function Playlists() {
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedPlaylists.length === 0) return;
|
||||
let deleted = 0;
|
||||
for (const pl of selectedPlaylists) {
|
||||
try {
|
||||
await deletePlaylist(pl.id);
|
||||
removeId(pl.id);
|
||||
deleted++;
|
||||
} catch {
|
||||
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
|
||||
}
|
||||
}
|
||||
setPlaylists((prev) => prev.filter((p) => !selectedIds.has(p.id)));
|
||||
clearSelection();
|
||||
if (deleted > 0) {
|
||||
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
|
||||
if (selectedPlaylists.length === 0) return;
|
||||
try {
|
||||
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
|
||||
const targetIds = new Set(targetSongs.map(s => s.id));
|
||||
let totalAdded = 0;
|
||||
|
||||
for (const pl of selectedPlaylists) {
|
||||
if (pl.id === targetPlaylist.id) continue;
|
||||
const { songs } = await getPlaylist(pl.id);
|
||||
const newSongs = songs.filter(s => !targetIds.has(s.id));
|
||||
if (newSongs.length > 0) {
|
||||
newSongs.forEach(s => targetIds.add(s.id));
|
||||
totalAdded += newSongs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
|
||||
touchPlaylist(targetPlaylist.id);
|
||||
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
|
||||
} else {
|
||||
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||
}
|
||||
clearSelection();
|
||||
} catch {
|
||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
@@ -91,34 +166,51 @@ export default function Playlists() {
|
||||
|
||||
{/* ── Header row ── */}
|
||||
<div className="playlists-header">
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('playlists.selectionCount', { count: selectedIds.size })
|
||||
: t('playlists.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
{t('playlists.create')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
className="input"
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('playlists.createName')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
{t('playlists.create')}
|
||||
</button>
|
||||
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
|
||||
{t('playlists.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
<Plus size={15} /> {t('playlists.newPlaylist')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
||||
onClick={toggleSelectionMode}
|
||||
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
||||
>
|
||||
<CheckSquare2 size={15} />
|
||||
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,10 +222,34 @@ export default function Playlists() {
|
||||
{playlists.map((pl) => (
|
||||
<div
|
||||
key={pl.id}
|
||||
className="album-card"
|
||||
onClick={() => navigate(`/playlists/${pl.id}`)}
|
||||
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(pl.id);
|
||||
} else {
|
||||
navigate(`/playlists/${pl.id}`);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
|
||||
style={selectionMode && selectedIds.has(pl.id) ? {
|
||||
outline: '2px solid var(--accent)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-md)'
|
||||
} : {}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
|
||||
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
{/* Cover area — server collage or fallback icon */}
|
||||
<div className="album-card-cover">
|
||||
{pl.coverArt ? (
|
||||
@@ -163,15 +279,6 @@ export default function Playlists() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete button — top-right corner */}
|
||||
<button
|
||||
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(e, pl)}
|
||||
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="album-card-info">
|
||||
|
||||
@@ -174,10 +174,10 @@ interface PlayerState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
|
||||
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
|
||||
queueIndex?: number;
|
||||
};
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
|
||||
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number) => void;
|
||||
closeContextMenu: () => void;
|
||||
|
||||
songInfoModal: { isOpen: boolean; songId: string | null };
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
|
||||
/* ─ Album Card ─ */
|
||||
.album-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -519,6 +520,33 @@
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ─── Artist Card Selection Checkbox ─── */
|
||||
.artist-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.artist-card-select-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.artist-card-select-check--on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
/* ── Albums selection bar (portal) ── */
|
||||
.albums-selection-bar {
|
||||
position: fixed;
|
||||
|
||||
@@ -2306,3 +2306,143 @@
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Sidebar Expandable Playlists Section ─── */
|
||||
.sidebar-playlists-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-playlists-main-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlists-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-playlists-header:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlists-header.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-chevron {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlists-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-playlists-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--space-4);
|
||||
padding-top: var(--space-1);
|
||||
padding-bottom: var(--space-1);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar-playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-playlist-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-playlist-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-playlist-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-playlists-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-playlists-empty {
|
||||
padding: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user