feat: add search results page and richer navigation

This commit is contained in:
2026-04-03 01:40:30 +03:00
parent 56aa822730
commit 3abc864abd
6 changed files with 194 additions and 10 deletions

View File

@@ -10,6 +10,7 @@ import { HomePage } from '@/pages/home-page'
import { LoginPage } from '@/pages/login-page'
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
import { PlaylistsPage } from '@/pages/playlists-page'
import { SearchPage } from '@/pages/search-page'
import { TracksPage } from '@/pages/tracks-page'
import { useSessionStore } from '@/stores/session-store'
@@ -33,6 +34,7 @@ export default function App() {
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -150,11 +150,21 @@ export function CommandPalette({
label={track.title}
meta={`${track.artistName}${track.albumTitle}`}
onClick={() => {
navigate('/tracks')
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
onClose()
}}
/>
))}
{(searchQuery.data?.artists.length || searchQuery.data?.albums.length || searchQuery.data?.tracks.length) ? (
<PaletteRow
label={`Показать все результаты по "${deferredQuery}"`}
meta="Страница поиска"
onClick={() => {
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
onClose()
}}
/>
) : null}
</div>
</div>
) : (
@@ -177,7 +187,7 @@ export function CommandPalette({
return
}
if (command.action === 'navigate') {
navigate('/tracks')
navigate('/search')
onClose()
return
}

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { coverArtUrl, fetchAlbums } from '@/lib/api'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
export function AlbumsPage() {
const navigate = useNavigate()
const albumsQuery = useQuery({
queryKey: ['albums'],
queryFn: fetchAlbums,
@@ -17,7 +18,7 @@ export function AlbumsPage() {
<button className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white" type="button">
Недавно добавленные
</button>
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={() => navigate('/search')} type="button">
<Search size={18} />
</button>
</div>

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { SlidersHorizontal } from 'lucide-react'
import { Search, SlidersHorizontal } from 'lucide-react'
import { coverArtUrl, fetchArtists } from '@/lib/api'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
export function ArtistsPage() {
const navigate = useNavigate()
const artistsQuery = useQuery({
queryKey: ['artists'],
queryFn: fetchArtists,
@@ -20,7 +21,10 @@ export function ArtistsPage() {
</button>
</div>
<div className="max-w-sm rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400">Поиск...</div>
<button className="flex max-w-sm items-center gap-3 rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400" onClick={() => navigate('/search')} type="button">
<Search size={16} />
Поиск...
</button>
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#0f182a]">
<div className="grid grid-cols-[56px_minmax(0,2fr)_260px] gap-4 border-b border-[#24314f] px-5 py-3 text-base text-slate-300">

View File

@@ -0,0 +1,165 @@
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { FormEvent, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, searchLibrary } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function SearchPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const initialQuery = params.get('q') ?? ''
const [query, setQuery] = useState(initialQuery)
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const searchQuery = useQuery({
queryKey: ['search-page', initialQuery],
queryFn: () => searchLibrary(initialQuery),
enabled: initialQuery.trim().length > 0,
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const results = searchQuery.data
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
function onSubmit(event: FormEvent) {
event.preventDefault()
navigate(`/search?q=${encodeURIComponent(query.trim())}`)
}
return (
<div className="space-y-8">
<form className="flex items-center gap-3" onSubmit={onSubmit}>
<div className="flex flex-1 items-center gap-3 rounded-[12px] border border-[#24314f] bg-[#0d1628] px-4 py-3">
<Search size={18} className="text-slate-400" />
<input
className="w-full bg-transparent text-base text-white outline-none placeholder:text-slate-500"
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск альбома, исполнителя или трека"
value={query}
/>
</div>
<button className="rounded-[10px] bg-[#16bf8c] px-5 py-3 text-base font-medium text-[#081225]" type="submit">
Искать
</button>
</form>
{!initialQuery ? (
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
Введи название трека, альбома или исполнителя.
</div>
) : null}
{results?.artists.length ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Исполнители</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{results.artists.map((artist) => (
<Link
key={artist.id}
className="flex items-center justify-between rounded-[12px] border border-[#24314f] bg-[#121b2e] px-5 py-4 transition hover:border-[#39527e] hover:bg-[#16233b]"
to={`/artists/${artist.id}`}
>
<div className="flex min-w-0 items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-[#303b4d]">
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-medium text-white">{artist.name}</div>
<div className="text-sm text-slate-400">{artist.albumCount} альбомов</div>
</div>
</div>
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} entityId={artist.id} entityType="artist" />
</Link>
))}
</div>
</section>
) : null}
{results?.albums.length ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Альбомы</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{results.albums.map((album) => (
<article key={album.id}>
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<Link className="line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
{album.title}
</Link>
<div className="text-base text-slate-400">{album.artistName}</div>
</div>
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} entityId={album.id} entityType="album" />
</div>
</article>
))}
</div>
</section>
) : null}
{results?.tracks.length ? (
<section className="space-y-4">
<div className="text-[2rem] font-semibold tracking-tight text-white">Треки</div>
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
<div>#</div>
<div>Название</div>
<div>Альбом</div>
<div></div>
<div></div>
</div>
{results.tracks.map((track, index) => (
<button
key={track.id}
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
onClick={() => {
setQueue(results.tracks, index)
playTrack(track, results.tracks)
}}
type="button"
>
<div className="text-lg text-slate-200">{index + 1}</div>
<div className="flex min-w-0 items-center gap-3">
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
</div>
<div className="min-w-0">
<div className="truncate text-[1.02rem] text-white">{track.title}</div>
<div className="truncate text-base text-slate-400">{track.artistName}</div>
</div>
</div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="grid place-items-center">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
</div>
</button>
))}
</div>
</section>
) : null}
{initialQuery && !searchQuery.isLoading && !results?.artists.length && !results?.albums.length && !results?.tracks.length ? (
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
Ничего не найдено по запросу "{initialQuery}".
</div>
) : null}
</div>
)
}
function formatDuration(durationSeconds: number) {
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -2,9 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { useNavigate } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store'
export function TracksPage() {
const navigate = useNavigate()
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const tracksQuery = useQuery({
@@ -21,7 +23,7 @@ export function TracksPage() {
return (
<div className="space-y-4">
<HeaderSearch />
<HeaderSearch onClick={() => navigate('/search')} />
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_140px_180px_120px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
<div>#</div>
@@ -74,10 +76,10 @@ export function TracksPage() {
)
}
function HeaderSearch() {
function HeaderSearch({ onClick }: { onClick: () => void }) {
return (
<div className="flex items-center justify-end">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={onClick} type="button">
<Search size={18} />
</button>
</div>