Initial commit

This commit is contained in:
2026-10-05 20:38:28 +02:00
commit 60a68b420e
107 changed files with 21716 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Heart, Play, Plus } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { tracksApi } from "../lib/api";
import { formatDate, formatDuration } from "../lib/format";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal";
import { ArtistLinks } from "../components/ArtistLinks";
import { Comments } from "../components/Comments";
import { Cover } from "../components/Cover";
import { LyricsView } from "../components/LyricsView";
import { ShareButton } from "../components/ShareButton";
export function TrackPage() {
const { id = "" } = useParams();
const playNow = usePlayer((s) => s.playNow);
const addToQueue = usePlayer((s) => s.addToQueue);
const qc = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["track", id],
queryFn: () => tracksApi.get(id),
enabled: Boolean(id),
});
const like = useMutation({
mutationFn: () => (data?.liked ? tracksApi.unlike(id) : tracksApi.like(id)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["track", id] });
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Трек не найден.</p>;
const { track } = data;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
<Cover src={track.coverUrl} name={track.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
<div className="min-w-0 flex-1">
<span className="chip">Трек</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{track.title}</h1>
<p className="mt-1 text-sm text-zinc-400">
<ArtistLinks artists={track.artists} fallback={track.artist?.name} linkClassName="link" />
{track.album && (
<>
{" · "}
<Link to={`/album/${track.album.id}`} className="link">
{track.album.title}
</Link>
</>
)}
</p>
<p className="mt-1 text-sm text-zinc-500">
{formatDuration(track.durationMs)}
{track.releaseDate && ` · ${formatDate(track.releaseDate)}`}
{` · ${track.playCount} прослушиваний`}
</p>
<p className="mt-2 text-xs text-zinc-500">
Добавил в библиотеку:{" "}
{track.uploadedBy ? (
<Link to={`/u/${track.uploadedBy.username}`} className="link">
{track.uploadedBy.displayName || track.uploadedBy.username}
</Link>
) : (
"неизвестно"
)}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playNow(track, [track])}>
<Play size={15} /> Играть
</button>
<button className="btn-ghost" onClick={() => addToQueue(track)}>
<Plus size={15} /> В очередь
</button>
<button className={data.liked ? "btn-danger" : "btn-ghost"} onClick={() => like.mutate()}>
<Heart size={15} fill={data.liked ? "currentColor" : "none"} />
{data.liked ? "В избранном" : "В избранное"}
</button>
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
В плейлист
</button>
<a className="btn-ghost" href={`/api/tracks/${id}/stream?download=1`}>
Скачать
</a>
<ShareButton path={`/track/${id}`} />
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<section className="card max-h-[60vh] overflow-hidden p-2">
<h2 className="section-title px-3 pb-2 pt-3">Текст песни</h2>
<LyricsView trackId={id} />
</section>
<Comments trackId={id} />
</div>
<AddToPlaylistModal track={track} open={addOpen} onClose={() => setAddOpen(false)} />
</div>
);
}