优化音乐界面
This commit is contained in:
@@ -1884,20 +1884,14 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/music/search') {
|
if (pathname === '/music/search') {
|
||||||
const source = normalizeSource(searchParams.get('source') || currentSource);
|
|
||||||
const keyword = searchParams.get('q') || '';
|
const keyword = searchParams.get('q') || '';
|
||||||
setCurrentSource(source);
|
|
||||||
setCurrentView('search');
|
setCurrentView('search');
|
||||||
setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索');
|
setCurrentPlaylistTitle(keyword ? `搜索: ${keyword}` : '搜索');
|
||||||
setSearchKeyword(keyword);
|
setSearchKeyword(keyword);
|
||||||
if (keyword) {
|
setSongs([]);
|
||||||
void searchSongs(keyword, false);
|
setActiveSearchKeyword('');
|
||||||
} else {
|
setSearchPage(1);
|
||||||
setSongs([]);
|
setSearchHasMore(false);
|
||||||
setActiveSearchKeyword('');
|
|
||||||
setSearchPage(1);
|
|
||||||
setSearchHasMore(false);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2627,48 +2621,6 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg text-white">音乐</span>
|
<span className="font-bold text-lg text-white">音乐</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSourceMenu(true)}
|
|
||||||
className="relative h-10 min-w-[132px] rounded-full border border-white/10 bg-gradient-to-r from-white/8 to-white/4 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] px-3"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-between px-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-green-500/15 text-green-400 flex items-center justify-center shrink-0">
|
|
||||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[9px] uppercase tracking-[0.18em] text-zinc-500 leading-none">音源</div>
|
|
||||||
<div className="text-sm font-medium text-white leading-tight truncate">
|
|
||||||
{musicSources.find((source) => source.key === currentSource)?.label || '酷我'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-7 h-7 rounded-full bg-white/6 border border-white/8 flex items-center justify-center text-zinc-300 shrink-0">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex flex-wrap bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
|
|
||||||
{musicSources.map((source) => (
|
|
||||||
<button
|
|
||||||
key={source.key}
|
|
||||||
onClick={() => switchSource(source.key)}
|
|
||||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
|
||||||
currentSource === source.key
|
|
||||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
|
||||||
: 'text-zinc-400 border border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{source.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -3530,61 +3482,6 @@ export default function MusicClient({ children: _children }: { children?: React.
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSourceMenu && (
|
|
||||||
<div className="md:hidden fixed inset-0 z-[90]">
|
|
||||||
<button
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={() => setShowSourceMenu(false)}
|
|
||||||
aria-label="关闭音源菜单"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-white/10 bg-zinc-950/98 px-4 pb-6 pt-4 shadow-2xl">
|
|
||||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-white/15" />
|
|
||||||
<div className="mb-3 px-1 text-sm font-medium text-white">切换音源</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{musicSources.map((source) => {
|
|
||||||
const active = currentSource === source.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.key}
|
|
||||||
onClick={() => {
|
|
||||||
setShowSourceMenu(false);
|
|
||||||
if (!active) switchSource(source.key);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-all ${
|
|
||||||
active
|
|
||||||
? 'border-green-500/50 bg-green-500/12 text-white'
|
|
||||||
: 'border-white/8 bg-white/5 text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${active ? 'bg-green-500/20 text-green-400' : 'bg-white/8 text-zinc-400'}`}>
|
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-base font-medium">{source.label}</div>
|
|
||||||
</div>
|
|
||||||
{active ? (
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-zinc-500">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add to Playlist Modal */}
|
{/* Add to Playlist Modal */}
|
||||||
<AddToPlaylistModal
|
<AddToPlaylistModal
|
||||||
song={songToAddToPlaylist}
|
song={songToAddToPlaylist}
|
||||||
|
|||||||
@@ -36,19 +36,26 @@ export default function MusicRankingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-5 hidden flex-wrap gap-2 md:flex">
|
<div className="mb-6 flex items-center justify-between gap-3 border-b border-white/5 pb-3">
|
||||||
{musicSources.map((source) => (
|
<h2 className="shrink-0 text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
||||||
<button
|
<div className="flex max-w-[calc(100vw-7rem)] justify-end overflow-x-auto rounded-full border border-white/10 bg-white/[0.04] p-1 shadow-inner shadow-black/20 md:max-w-none md:overflow-visible">
|
||||||
key={source.key}
|
{musicSources.map((source) => {
|
||||||
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
|
const active = currentSource === source.key;
|
||||||
className={`rounded-lg border px-3 py-1.5 text-xs font-bold ${currentSource === source.key ? 'border-green-500 bg-green-500 text-white' : 'border-white/10 bg-white/5 text-zinc-400'}`}
|
return (
|
||||||
>
|
<button
|
||||||
{source.label}
|
key={source.key}
|
||||||
</button>
|
onClick={() => router.push(`/music/rankings?source=${source.key}`)}
|
||||||
))}
|
className={`shrink-0 rounded-full px-3 py-1.5 text-xs font-bold transition-all ${
|
||||||
</div>
|
active
|
||||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
? 'bg-gradient-to-r from-green-500 to-emerald-400 text-white shadow-lg shadow-green-500/25'
|
||||||
<h2 className="text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
: 'text-zinc-400 hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <MusicLoadingIndicator className="py-8" /> : playlists.length > 0 ? (
|
{loading ? <MusicLoadingIndicator className="py-8" /> : playlists.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { playMusicList } from '@/lib/music/actions';
|
import { playMusicList } from '@/lib/music/actions';
|
||||||
import { MusicLoadingIndicator, type Song } from '../MusicClient';
|
import { MusicLoadingIndicator, type Song } from '../MusicClient';
|
||||||
import SongList from '../SongList';
|
import SongList from '../SongList';
|
||||||
import { mapSong, normalizeSource } from '@/lib/music/shared';
|
import { mapSong, musicSources, normalizeSource } from '@/lib/music/shared';
|
||||||
|
|
||||||
export default function MusicSearchPage() {
|
export default function MusicSearchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -13,21 +13,28 @@ export default function MusicSearchPage() {
|
|||||||
const source = normalizeSource(searchParams.get('source'));
|
const source = normalizeSource(searchParams.get('source'));
|
||||||
const q = searchParams.get('q') || '';
|
const q = searchParams.get('q') || '';
|
||||||
const [keyword, setKeyword] = useState(q);
|
const [keyword, setKeyword] = useState(q);
|
||||||
|
const [selectedSource, setSelectedSource] = useState(source);
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showSourceMenu, setShowSourceMenu] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSelectedSource(source);
|
||||||
setKeyword(q);
|
setKeyword(q);
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setSongs([]);
|
setSongs([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`)
|
fetch(`/api/music/v2/search?source=${source}&q=${encodeURIComponent(q)}&page=1&limit=20`, { signal: controller.signal })
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => setSongs((data.data?.list || []).map(mapSong)))
|
.then((data) => setSongs((data.data?.list || []).map(mapSong)))
|
||||||
.catch(() => setSongs([]))
|
.catch((error) => {
|
||||||
|
if (error?.name !== 'AbortError') setSongs([]);
|
||||||
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
}, [source, q]);
|
}, [source, q]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -35,16 +42,81 @@ export default function MusicSearchPage() {
|
|||||||
if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`);
|
if (next) router.push(`/music/search?source=${source}&q=${encodeURIComponent(next)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeSource = (nextSource: string) => {
|
||||||
|
const normalizedSource = normalizeSource(nextSource);
|
||||||
|
const next = keyword.trim() || q;
|
||||||
|
setSelectedSource(normalizedSource);
|
||||||
|
setShowSourceMenu(false);
|
||||||
|
router.push(`/music/search?source=${normalizedSource}${next ? `&q=${encodeURIComponent(next)}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSourceLabel = musicSources.find((item) => item.key === selectedSource)?.label || '音源';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center gap-2">
|
<div className="mb-6 flex items-center gap-2">
|
||||||
<div className="relative h-11 flex-1 rounded-xl bg-white/10">
|
<div className="relative h-11 flex-1 rounded-xl border border-white/10 bg-white/10">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||||
<svg className="h-4 w-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
<svg className="h-4 w-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-4 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." />
|
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} className="h-full w-full border-0 bg-transparent pl-10 pr-28 text-sm text-white outline-none placeholder:text-zinc-500" placeholder="搜索歌曲或艺术家..." />
|
||||||
|
<div className="absolute inset-y-0 right-2 flex items-center">
|
||||||
|
<div className="relative h-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSourceMenu((open) => !open)}
|
||||||
|
className="flex h-8 min-w-[84px] items-center justify-between gap-1 rounded-lg border border-white/10 bg-zinc-900/90 pl-3 pr-2 text-xs font-medium text-white shadow-inner shadow-white/5 transition-colors hover:bg-zinc-800"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={showSourceMenu}
|
||||||
|
aria-label="选择音源"
|
||||||
|
>
|
||||||
|
<span>{currentSourceLabel}</span>
|
||||||
|
<svg className={`h-3.5 w-3.5 text-zinc-400 transition-transform ${showSourceMenu ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{showSourceMenu && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 cursor-default"
|
||||||
|
onClick={() => setShowSourceMenu(false)}
|
||||||
|
aria-label="关闭音源菜单"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-10 z-50 w-32 overflow-hidden rounded-xl border border-white/10 bg-zinc-900/95 p-1 shadow-2xl shadow-black/50 backdrop-blur-md" role="listbox">
|
||||||
|
{musicSources.map((item) => {
|
||||||
|
const active = item.key === selectedSource;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeSource(item.key)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
|
||||||
|
active ? 'bg-green-500/20 text-green-300' : 'text-zinc-300 hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{active && (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={submit} className="h-11 rounded-xl bg-green-600 px-5 text-sm font-medium text-white hover:bg-green-700">搜索</button>
|
<button onClick={submit} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-green-600 text-white hover:bg-green-700" aria-label="搜索" title="搜索">
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user