185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||
import { type Track, coverArtUrl, fetchHome, fetchRecentlyPlayed, fetchTracks } from '@/lib/api'
|
||
import { Link } from 'react-router-dom'
|
||
import { usePlayerStore } from '@/stores/player-store'
|
||
|
||
export function HomePage() {
|
||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||
const homeQuery = useQuery({
|
||
queryKey: ['home'],
|
||
queryFn: fetchHome,
|
||
})
|
||
const tracksQuery = useQuery({
|
||
queryKey: ['tracks'],
|
||
queryFn: fetchTracks,
|
||
})
|
||
const recentTracksQuery = useQuery({
|
||
queryKey: ['recently-played'],
|
||
queryFn: fetchRecentlyPlayed,
|
||
})
|
||
|
||
const allTracks = tracksQuery.data?.items ?? []
|
||
const heroTrack = allTracks[0]
|
||
const recentAlbums = homeQuery.data?.recentAlbums ?? []
|
||
const recentTracks = recentTracksQuery.data?.items ?? homeQuery.data?.recentTracks ?? []
|
||
const popularAlbums = [...recentAlbums].reverse()
|
||
|
||
if (homeQuery.isLoading || tracksQuery.isLoading) {
|
||
return <LoadingPanel title="Загружаю домашнюю страницу" />
|
||
}
|
||
|
||
if (homeQuery.isError || tracksQuery.isError) {
|
||
return (
|
||
<ErrorPanel
|
||
onRetry={() => {
|
||
void homeQuery.refetch()
|
||
void tracksQuery.refetch()
|
||
void recentTracksQuery.refetch()
|
||
}}
|
||
title="Не получилось собрать главную"
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-10">
|
||
<section className="relative overflow-hidden rounded-[14px] bg-[#111b2e]">
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(168,135,87,0.35),transparent_34%),linear-gradient(180deg,rgba(20,30,47,0.4),rgba(14,20,35,0.92))]" />
|
||
<div className="relative flex min-h-[290px] items-end gap-4 px-8 py-6">
|
||
<div className="h-64 w-64 shrink-0 overflow-hidden rounded-[14px] bg-[#1a2437] shadow-2xl">
|
||
{heroTrack?.coverArtId ? (
|
||
<img alt={heroTrack.title} className="h-full w-full object-cover" src={coverArtUrl(heroTrack.id)} />
|
||
) : null}
|
||
</div>
|
||
<div className="pb-3">
|
||
<h2 className="text-6xl font-semibold tracking-tight text-white">
|
||
{heroTrack?.title ?? 'Dream on (Live in Paris, 2001)'}
|
||
</h2>
|
||
<div className="mt-2 text-3xl font-medium text-slate-300">{heroTrack?.artistName ?? 'Depeche Mode'}</div>
|
||
<div className="mt-4 flex gap-2">
|
||
<Tag>{new Date().getFullYear()}</Tag>
|
||
<Tag>{formatDuration(heroTrack?.durationSeconds ?? 339)}</Tag>
|
||
</div>
|
||
</div>
|
||
<div className="ml-auto flex gap-2 self-center">
|
||
<CarouselButton icon={<ChevronLeft size={18} />} />
|
||
<CarouselButton icon={<ChevronRight size={18} />} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<TrackRow title="Недавно прослушанные" tracks={recentTracks} onPlayAll={() => setQueue(recentTracks)} />
|
||
<AlbumRow title="Недавно добавленные" albums={recentAlbums} onPlayAll={() => setQueue(allTracks)} />
|
||
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(allTracks)} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TrackRow({
|
||
title,
|
||
tracks,
|
||
onPlayAll,
|
||
}: {
|
||
title: string
|
||
tracks: Track[]
|
||
onPlayAll: () => void
|
||
}) {
|
||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||
return (
|
||
<section>
|
||
<div className="mb-5 flex items-center justify-between">
|
||
<h3 className="text-[2rem] font-semibold tracking-tight text-white">{title}</h3>
|
||
<div className="flex items-center gap-3">
|
||
<button className="text-base text-slate-400 transition hover:text-white" onClick={onPlayAll} type="button">
|
||
Еще
|
||
</button>
|
||
<CarouselButton icon={<ChevronLeft size={18} />} />
|
||
<CarouselButton icon={<ChevronRight size={18} />} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||
{tracks.map((track) => (
|
||
<button
|
||
key={track.id}
|
||
className="text-left"
|
||
onClick={() => playTrack(track, tracks)}
|
||
type="button"
|
||
>
|
||
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
|
||
{track.coverArtId ? (
|
||
<img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} />
|
||
) : null}
|
||
</div>
|
||
<div className="mt-3 line-clamp-1 block text-[1.08rem] font-semibold text-white">{track.title}</div>
|
||
<div className="line-clamp-1 text-base text-slate-400">{track.artistName}</div>
|
||
<div className="line-clamp-1 text-sm text-slate-500">{track.albumTitle}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function AlbumRow({
|
||
title,
|
||
albums,
|
||
onPlayAll,
|
||
}: {
|
||
title: string
|
||
albums: Array<{
|
||
id: string
|
||
title: string
|
||
artistName: string
|
||
coverArtId: string
|
||
}>
|
||
onPlayAll: () => void
|
||
}) {
|
||
return (
|
||
<section>
|
||
<div className="mb-5 flex items-center justify-between">
|
||
<h3 className="text-[2rem] font-semibold tracking-tight text-white">{title}</h3>
|
||
<div className="flex items-center gap-3">
|
||
<button className="text-base text-slate-400 transition hover:text-white" onClick={onPlayAll} type="button">
|
||
Еще
|
||
</button>
|
||
<CarouselButton icon={<ChevronLeft size={18} />} />
|
||
<CarouselButton icon={<ChevronRight size={18} />} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||
{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>
|
||
<Link className="mt-3 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>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function CarouselButton({ icon }: { icon: React.ReactNode }) {
|
||
return <button className="grid h-8 w-8 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">{icon}</button>
|
||
}
|
||
|
||
function Tag({ children }: { children: React.ReactNode }) {
|
||
return <div className="rounded-full bg-[#f1f4f8] px-4 py-1 text-sm font-semibold text-[#152035]">{children}</div>
|
||
}
|
||
|
||
function formatDuration(durationSeconds: number) {
|
||
const minutes = Math.floor(durationSeconds / 60)
|
||
const seconds = durationSeconds % 60
|
||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||
}
|