feat: add search results page and richer navigation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
165
apps/web/src/pages/search-page.tsx
Normal file
165
apps/web/src/pages/search-page.tsx
Normal 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')}`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user