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

8
apps/web/Dockerfile.dev Normal file
View File

@@ -0,0 +1,8 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json* ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
EXPOSE 5173
CMD ["npm", "run", "dev", "-w", "@adc-retivo/web", "--", "--host", "0.0.0.0"]

19
apps/web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#07080c" />
<title>ADC Retivo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
apps/web/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@adc-retivo/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.62.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

66
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Account } from "./pages/Account";
import { AddTrack } from "./pages/AddTrack";
import { Admin } from "./pages/Admin";
import { AlbumPage } from "./pages/AlbumPage";
import { ArtistPage } from "./pages/ArtistPage";
import { Home } from "./pages/Home";
import { Library } from "./pages/Library";
import { Login } from "./pages/Login";
import { PlaylistPage } from "./pages/PlaylistPage";
import { SearchPage } from "./pages/SearchPage";
import { TrackPage } from "./pages/TrackPage";
import { UserPage } from "./pages/UserPage";
import { useAuth } from "./store/auth";
function Splash() {
return (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse-slow text-lg font-semibold tracking-widest text-white/70">
ADC RETIVO
</div>
</div>
);
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <Splash />;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export function App() {
const { user, loading } = useAuth();
return (
<Routes>
<Route
path="/login"
element={loading ? <Splash /> : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/" element={<Home />} />
<Route path="/library" element={<Library />} />
<Route path="/add" element={<AddTrack />} />
<Route path="/account" element={<Account />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/playlist/:id" element={<PlaylistPage />} />
<Route path="/artist/:id" element={<ArtistPage />} />
<Route path="/album/:id" element={<AlbumPage />} />
<Route path="/track/:id" element={<TrackPage />} />
<Route path="/u/:username" element={<UserPage />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/:section" element={<Admin />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus, X } from "lucide-react";
import { playlistsApi } from "../lib/api";
import type { Track } from "../lib/types";
import { Modal } from "./Modal";
export function AddToPlaylistModal({
track,
open,
onClose,
}: {
track: Track | null;
open: boolean;
onClose: () => void;
}) {
const qc = useQueryClient();
const [createdId, setCreatedId] = useState<string | null>(null);
const { data: playlists, isLoading } = useQuery({
queryKey: ["my-playlists"],
queryFn: playlistsApi.mine,
enabled: open,
});
const add = useMutation({
mutationFn: (playlistId: string) => playlistsApi.addTrack(playlistId, track!.id),
onSuccess: (_d, playlistId) => {
setCreatedId(playlistId);
void qc.invalidateQueries({ queryKey: ["playlist", playlistId] });
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
},
});
const createAndAdd = useMutation({
mutationFn: async () => {
const pl = await playlistsApi.create({ title: `Плейлист · ${track?.title ?? ""}`.slice(0, 80) });
await playlistsApi.addTrack(pl.id, track!.id);
return pl;
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
onClose();
},
});
return (
<Modal open={open} onClose={onClose} title="Добавить в плейлист">
<div className="space-y-2">
<button
className="btn-primary w-full"
onClick={() => createAndAdd.mutate()}
disabled={createAndAdd.isPending}
>
<Plus size={15} /> Новый плейлист
</button>
<div className="max-h-72 space-y-1 overflow-y-auto pt-2">
{isLoading && <p className="text-sm text-zinc-500">Загрузка...</p>}
{playlists?.map((pl) => (
<button
key={pl.id}
onClick={() => add.mutate(pl.id)}
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5"
>
<span className="truncate">{pl.title}</span>
{createdId === pl.id ? (
<Check size={15} className="text-emerald-400" />
) : (
<Plus size={15} className="text-zinc-500" />
)}
</button>
))}
{playlists && playlists.length === 0 && (
<p className="px-3 text-sm text-zinc-500">У вас пока нет плейлистов.</p>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,37 @@
import { Link } from "react-router-dom";
import type { TrackArtistCredit } from "../lib/types";
/**
* Renders one or more artist credits as individual profile links,
* e.g. "Дора, Мукка feat. Мэйби Бэйби".
*/
export function ArtistLinks({
artists,
fallback,
className,
linkClassName = "hover:underline",
onNavigate,
}: {
artists?: TrackArtistCredit[] | null;
fallback?: string | null;
className?: string;
linkClassName?: string;
onNavigate?: () => void;
}) {
const list = artists?.length ? artists : [];
if (!list.length) {
return <span className={className}>{fallback ?? "Неизвестный исполнитель"}</span>;
}
return (
<span className={className}>
{list.map((a, i) => (
<span key={a.id}>
{i > 0 && <span className="text-zinc-600">, </span>}
<Link to={`/artist/${a.id}`} className={linkClassName} onClick={onNavigate}>
{a.name}
</Link>
</span>
))}
</span>
);
}

View File

@@ -0,0 +1,168 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CornerDownRight, Send, Trash2 } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { tracksApi } from "../lib/api";
import { formatDate } from "../lib/format";
import type { Comment } from "../lib/types";
import { useAuth } from "../store/auth";
import { Cover } from "./Cover";
export function Comments({ trackId, compact = false }: { trackId: string; compact?: boolean }) {
const { user } = useAuth();
const qc = useQueryClient();
const [body, setBody] = useState("");
const { data } = useQuery({
queryKey: ["comments", trackId],
queryFn: () => tracksApi.comments(trackId),
});
const invalidate = () => void qc.invalidateQueries({ queryKey: ["comments", trackId] });
const add = useMutation({
mutationFn: ({ text, parentId }: { text: string; parentId?: string }) =>
tracksApi.addComment(trackId, text, parentId ?? null),
onSuccess: () => {
setBody("");
invalidate();
},
});
const remove = useMutation({
mutationFn: (commentId: string) => tracksApi.deleteComment(commentId),
onSuccess: invalidate,
});
return (
<div className={compact ? "" : "card p-4"}>
<h2 className="section-title mb-3">Комментарии ({data?.total ?? 0})</h2>
<form
className="mb-4 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (body.trim()) add.mutate({ text: body.trim() });
}}
>
<input
className="input"
placeholder="Оставить комментарий..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<button className="btn-primary px-3" disabled={!body.trim() || add.isPending}>
<Send size={15} />
</button>
</form>
<div className="space-y-4">
{data?.comments.map((c) => (
<CommentNode
key={c.id}
comment={c}
currentUserId={user?.id ?? ""}
onReply={(text, parentId) => add.mutate({ text, parentId })}
onDelete={(cid) => remove.mutate(cid)}
/>
))}
{data && data.comments.length === 0 && (
<p className="text-sm text-zinc-500">Комментариев пока нет. Будьте первым!</p>
)}
</div>
</div>
);
}
function CommentNode({
comment,
currentUserId,
onReply,
onDelete,
}: {
comment: Comment;
currentUserId: string;
onReply: (text: string, parentId: string) => void;
onDelete: (id: string) => void;
}) {
const [replyOpen, setReplyOpen] = useState(false);
const [reply, setReply] = useState("");
return (
<div className="text-sm">
<div className="flex items-start gap-2">
<Link to={comment.user ? `/u/${comment.user.username}` : "#"}>
<Cover
src={comment.user?.avatarUrl}
name={comment.user?.displayName || comment.user?.username}
rounded="rounded-full"
className="h-7 w-7 shrink-0"
/>
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{comment.user ? (
<Link to={`/u/${comment.user.username}`} className="link text-xs font-medium">
{comment.user.displayName || comment.user.username}
</Link>
) : (
<span className="text-xs text-zinc-500">удалён</span>
)}
<span className="text-[10px] text-zinc-600">{formatDate(comment.createdAt)}</span>
</div>
<p className={comment.deleted ? "italic text-zinc-600" : "text-zinc-200"}>
{comment.deleted ? "Комментарий удалён" : comment.body}
</p>
<div className="mt-1 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-white" onClick={() => setReplyOpen((v) => !v)}>
<CornerDownRight size={11} className="mr-1 inline" /> Ответить
</button>
{comment.user?.id === currentUserId && !comment.deleted && (
<button className="hover:text-red-300" onClick={() => onDelete(comment.id)}>
<Trash2 size={11} className="mr-1 inline" /> Удалить
</button>
)}
</div>
{replyOpen && (
<form
className="mt-2 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (reply.trim()) {
onReply(reply.trim(), comment.id);
setReply("");
setReplyOpen(false);
}
}}
>
<input
className="input py-1.5 text-xs"
placeholder="Ответ..."
value={reply}
onChange={(e) => setReply(e.target.value)}
/>
<button className="btn-primary px-3 py-1.5">
<Send size={13} />
</button>
</form>
)}
{comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l border-white/10 pl-3">
{comment.replies.map((r) => (
<CommentNode
key={r.id}
comment={r}
currentUserId={currentUserId}
onReply={onReply}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { cx, initials } from "../lib/format";
export function Cover({
src,
name,
className,
rounded = "rounded-xl",
}: {
src?: string | null;
name?: string | null;
className?: string;
rounded?: string;
}) {
return (
<div className={cx("relative overflow-hidden bg-white/5", rounded, className)}>
{src ? (
<img src={src} alt={name ?? ""} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="grid h-full w-full place-items-center text-xs font-semibold text-zinc-500">
{initials(name) || "AR"}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { playlistsApi } from "../lib/api";
import { Modal } from "./Modal";
export function CreatePlaylistModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated?: (id: string) => void;
}) {
const qc = useQueryClient();
const [form, setForm] = useState<{
title: string;
description: string;
visibility: "public" | "unlisted" | "private";
}>({ title: "", description: "", visibility: "private" });
const [error, setError] = useState("");
const create = useMutation({
mutationFn: () =>
playlistsApi.create({
title: form.title,
description: form.description || null,
visibility: form.visibility,
}),
onSuccess: (pl) => {
void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
setForm({ title: "", description: "", visibility: "private" });
onCreated?.(pl.id);
onClose();
},
onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"),
});
return (
<Modal open={open} onClose={onClose} title="Новый плейлист">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input
className="input"
autoFocus
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Мой плейлист"
/>
</div>
<div>
<label className="label">Описание</label>
<textarea
className="input min-h-20"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<label className="label">Доступ</label>
<select
className="input"
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as typeof form.visibility })} >
<option value="private">Приватный</option>
<option value="unlisted">По ссылке</option>
<option value="public">Публичный</option>
</select>
</div>
{error && <p className="text-sm text-red-300">{error}</p>}
<button
className="btn-primary w-full"
onClick={() => create.mutate()}
disabled={!form.title.trim() || create.isPending}
>
Создать
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,176 @@
import {
Compass,
Library as LibraryIcon,
PlusCircle,
Search as SearchIcon,
Settings,
Shield,
User as UserIcon,
} from "lucide-react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react";
import { cx } from "../lib/format";
import { canAccessAdmin } from "../lib/permissions";
import { useAuth } from "../store/auth";
import { Player } from "./Player";
const navItems = [
{ to: "/", label: "Главная", icon: Compass, end: true },
{ to: "/library", label: "Библиотека", icon: LibraryIcon },
{ to: "/add", label: "Добавить", icon: PlusCircle },
{ to: "/account", label: "Аккаунт", icon: UserIcon },
];
export function Layout() {
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [query, setQuery] = useState("");
const isAdmin = user ? canAccessAdmin(user) : false;
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = query.trim();
if (q) navigate(`/search?q=${encodeURIComponent(q)}`);
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1">
{/* Sidebar (desktop) */}
<aside className="hidden w-64 shrink-0 flex-col gap-2 p-4 md:flex">
<NavLink to="/" className="mb-4 flex items-center gap-3 px-2">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-accent to-accent2 text-sm font-black text-black">
AR
</span>
<span className="text-sm font-semibold tracking-widest text-white">
ADC RETIVO
</span>
</NavLink>
<nav className="flex flex-col gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cx(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
isActive
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cx(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
isActive || location.pathname.startsWith("/admin")
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)
}
>
<Shield size={18} />
Админ-панель
</NavLink>
)}
</nav>
</aside>
{/* Main */}
<main className="flex min-h-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/5 bg-black/30 px-4 py-3 backdrop-blur-xl md:px-6">
<div className="flex items-center gap-2 md:hidden">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-accent to-accent2 text-[10px] font-black text-black">
AR
</span>
</div>
<form onSubmit={submitSearch} className="relative flex-1">
<SearchIcon
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск треков, артистов, альбомов, плейлистов, людей..."
className="input pl-9"
/>
</form>
{user && (
<NavLink
to={`/u/${user.username}`}
className="hidden items-center gap-2 rounded-full border border-white/10 bg-white/5 py-1.5 pl-1.5 pr-3 text-xs text-zinc-300 hover:bg-white/10 sm:flex"
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/10 text-[10px]">
{user.username[0]?.toUpperCase()}
</span>
)}
<span className="max-w-[120px] truncate">
{user.displayName || user.username}
</span>
</NavLink>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-40 pt-4 md:px-6 md:pb-32">
<Outlet />
</div>
</main>
</div>
{/* Mobile bottom nav */}
<nav className="fixed inset-x-0 bottom-0 z-30 flex items-stretch justify-around border-t border-white/10 bg-black/60 backdrop-blur-xl md:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cx(
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
isActive ? "text-white" : "text-zinc-500",
)
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cx(
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
isActive ? "text-white" : "text-zinc-500",
)
}
>
<Shield size={18} />
Админ
</NavLink>
)}
</nav>
<Player />
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { tracksApi } from "../lib/api";
import { selectCurrentTrack, usePlayer } from "../store/player";
export function LyricsView({ trackId, compact = false }: { trackId: string; compact?: boolean }) {
const currentTime = usePlayer((s) => s.currentTime);
const seek = usePlayer((s) => s.seek);
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLParagraphElement>(null);
const { data, isLoading } = useQuery({
queryKey: ["lyrics", trackId],
queryFn: () => tracksApi.get(trackId),
enabled: Boolean(trackId),
staleTime: 5 * 60_000,
});
const synced = data?.lyrics.synced ?? null;
const plain = data?.lyrics.plain ?? null;
const activeIndex =
synced && synced.length
? synced.findIndex((line, i) => {
const next = synced[i + 1];
return currentTime * 1000 >= line.timeMs && (!next || currentTime * 1000 < next.timeMs);
})
: -1;
useEffect(() => {
if (activeRef.current && containerRef.current) {
activeRef.current.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [activeIndex]);
if (isLoading) {
return <p className="p-6 text-sm text-zinc-500">Загрузка текста...</p>;
}
if (!synced?.length && !plain) {
return (
<p className="p-6 text-sm text-zinc-500">
Текст песни не найден. Его можно добавить вручную в админ-панели.
</p>
);
}
if (synced?.length) {
return (
<div
ref={containerRef}
className={compact ? "max-h-64 overflow-y-auto px-2" : "max-h-full overflow-y-auto px-2 py-4"}
>
{synced.map((line, i) => (
<p
key={`${line.timeMs}-${i}`}
ref={i === activeIndex ? activeRef : undefined}
onClick={() => seek(line.timeMs / 1000)}
className={
"cursor-pointer rounded-lg px-3 py-1.5 text-lg font-semibold leading-snug transition " +
(i === activeIndex
? "bg-white/10 text-white"
: "text-zinc-500 hover:text-zinc-300")
}
>
{line.text || "♪"}
</p>
))}
</div>
);
}
return (
<pre className="whitespace-pre-wrap px-4 py-4 font-sans text-sm leading-relaxed text-zinc-300">
{plain}
</pre>
);
}

View File

@@ -0,0 +1,39 @@
import { X } from "lucide-react";
export function Modal({
open,
onClose,
title,
children,
wide = false,
}: {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
wide?: boolean;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className={
"w-full animate-fade-in rounded-2xl border border-white/10 bg-[#0d0f16]/95 p-5 shadow-2xl " +
(wide ? "max-w-3xl" : "max-w-lg")
}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-white">{title}</h3>
<button className="text-zinc-400 hover:text-white" onClick={onClose}>
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,655 @@
import {
ChevronDown,
Download,
ListMusic,
Maximize2,
MessageSquare,
Mic2,
Pause,
Play,
PlusCircle,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Volume2,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { cx, formatTime } from "../lib/format";
import { selectCurrentTrack, usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Comments } from "./Comments";
import { Cover } from "./Cover";
import { LyricsView } from "./LyricsView";
import { QueuePanel } from "./QueuePanel";
type Tab = "queue" | "lyrics" | "comments";
export function Player() {
const audioRef = useRef<HTMLAudioElement>(null);
const current = usePlayer(selectCurrentTrack);
const isPlaying = usePlayer((s) => s.isPlaying);
const shuffle = usePlayer((s) => s.shuffle);
const repeat = usePlayer((s) => s.repeat);
const expanded = usePlayer((s) => s.expanded);
const currentTime = usePlayer((s) => s.currentTime);
const duration = usePlayer((s) => s.duration);
const volume = usePlayer((s) => s.volume);
const toggle = usePlayer((s) => s.toggle);
const next = usePlayer((s) => s.next);
const prev = usePlayer((s) => s.prev);
const setExpanded = usePlayer((s) => s.setExpanded);
const setCurrentTime = usePlayer((s) => s.setCurrentTime);
const setDuration = usePlayer((s) => s.setDuration);
const setVolume = usePlayer((s) => s.setVolume);
const toggleShuffle = usePlayer((s) => s.toggleShuffle);
const cycleRepeat = usePlayer((s) => s.cycleRepeat);
const [queueOpen, setQueueOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [tab, setTab] = useState<Tab>("queue");
// Load a new source when the track changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !current) return;
const src = `/api/tracks/${current.id}/stream`;
if (!audio.src.endsWith(src)) {
audio.src = src;
audio.load();
}
if (usePlayer.getState().isPlaying) void audio.play().catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current?.id]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) void audio.play().catch(() => {});
else audio.pause();
}, [isPlaying]);
useEffect(() => {
const audio = audioRef.current;
if (audio) audio.volume = volume;
}, [volume]);
// System media session (lock screen / OS media controls)
useEffect(() => {
if (!("mediaSession" in navigator) || !current) return;
const artwork = current.coverUrl
? [
{
src: new URL(current.coverUrl, window.location.origin).href,
sizes: "500x500",
type: "image/jpeg",
},
]
: [];
navigator.mediaSession.metadata = new MediaMetadata({
title: current.title,
artist:
(current.artists?.length
? current.artists.map((a) => a.name)
: [current.artist?.name]
)
.filter(Boolean)
.join(", ") || "Неизвестный исполнитель",
album: current.album?.title ?? "",
artwork,
});
}, [current?.id]);
useEffect(() => {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
}, [isPlaying]);
// (Re)register Media Session handlers. iOS Safari (and some browsers) drop the
// action handlers when the audio source changes, so we register them on mount,
// on every track change and again when playback starts.
const registerMediaSession = useCallback(() => {
if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession;
const s = () => usePlayer.getState();
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => s().toggle()],
["pause", () => s().toggle()],
["stop", () => s().setPlaying(false)],
["previoustrack", () => s().prev()],
["nexttrack", () => s().next()],
["seekbackward", (d) => s().seek(Math.max(0, s().currentTime - (d.seekOffset ?? 10)))],
["seekforward", (d) => s().seek(s().currentTime + (d.seekOffset ?? 10))],
[
"seekto",
(d) => {
if (typeof d.seekTime === "number") s().seek(d.seekTime);
},
],
];
for (const [action, handler] of handlers) {
try {
ms.setActionHandler(action, handler);
} catch {
/* unsupported action */
}
}
}, []);
useEffect(() => {
registerMediaSession();
return () => {
if (!("mediaSession" in navigator)) return;
const actions: MediaSessionAction[] = [
"play",
"pause",
"stop",
"previoustrack",
"nexttrack",
"seekbackward",
"seekforward",
"seekto",
];
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, null);
} catch {
/* ignore */
}
}
};
}, [registerMediaSession]);
useEffect(() => {
registerMediaSession();
}, [current?.id, registerMediaSession]);
useEffect(() => {
if (!("mediaSession" in navigator) || !duration) return;
try {
navigator.mediaSession.setPositionState({
duration,
playbackRate: 1,
position: Math.min(currentTime, duration),
});
} catch {
/* ignore */
}
}, [currentTime, duration]);
const seek = (time: number) => {
const audio = audioRef.current;
if (audio) audio.currentTime = time;
setCurrentTime(time);
};
const uploader = current?.uploadedBy;
const progressPct = duration ? (currentTime / duration) * 100 : 0;
return (
<>
<audio
ref={audioRef}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onEnded={() => next()}
onPlay={registerMediaSession}
preload="metadata"
/>
{current && (
<>
{/* Mini player */}
<div className="fixed inset-x-0 bottom-14 z-30 border-t border-white/10 bg-black/50 backdrop-blur-2xl md:bottom-0">
<div className="flex items-center gap-3 px-3 py-2 md:px-4 md:py-3">
<button
onClick={() => setExpanded(true)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
title="Развернуть плеер"
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-white/5">
<Cover
src={current.coverUrl}
name={current.title}
className="h-11 w-11"
/>
<span className="absolute inset-0 hidden place-items-center bg-black/40 group-hover:grid">
<Maximize2 size={14} />
</span>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white">
{current.title}
</div>
<div className="truncate text-xs text-zinc-400">
{(current.artists?.length
? current.artists.map((a) => a.name)
: [current.artist?.name]
)
.filter(Boolean)
.join(", ") || "Неизвестный исполнитель"}
</div>
</div>
</button>
<div className="hidden flex-1 items-center gap-2 md:flex">
<button
className="text-zinc-300 hover:text-white"
onClick={prev}
title="Назад"
>
<SkipBack size={18} />
</button>
<button
className="grid h-9 w-9 place-items-center rounded-full bg-white text-black transition hover:scale-105"
onClick={toggle}
>
{isPlaying ? (
<Pause size={16} />
) : (
<Play size={16} className="translate-x-[1px]" />
)}
</button>
<button
className="text-zinc-300 hover:text-white"
onClick={next}
title="Вперёд"
>
<SkipForward size={18} />
</button>
<span className="ml-1 w-10 text-right text-[11px] tabular-nums text-zinc-500">
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="range-track flex-1"
/>
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
{formatTime(duration)}
</span>
</div>
<div className="flex items-center gap-1 md:gap-2">
<button
className={cx(
"hidden p-2 sm:inline-flex",
shuffle ? "text-accent" : "text-zinc-400 hover:text-white",
)}
onClick={toggleShuffle}
title="Перемешать"
>
<Shuffle size={16} />
</button>
<button
className={cx(
"hidden p-2 sm:inline-flex",
repeat !== "off"
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={cycleRepeat}
title="Повтор"
>
{repeat === "one" ? (
<Repeat1 size={16} />
) : (
<Repeat size={16} />
)}
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => setAddOpen(true)}
title="Добавить в плейлист"
>
<PlusCircle size={16} />
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => {
setTab("comments");
setExpanded(true);
}}
title="Комментарии"
>
<MessageSquare size={16} />
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => setQueueOpen((v) => !v)}
title="Очередь"
>
<ListMusic size={16} />
</button>
<button
className="hidden p-2 text-zinc-400 hover:text-white md:inline-flex"
onClick={toggle}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
</div>
</div>
<div className="h-0.5 w-full bg-white/10 md:hidden">
<div
className="h-full bg-gradient-to-r from-accent to-accent2"
style={{ width: `${progressPct}%` }}
/>
</div>
{queueOpen && (
<div className="absolute bottom-full right-2 mb-2 flex h-80 w-80 max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-white/10 bg-black/85 p-2 backdrop-blur-2xl">
<QueuePanel onClose={() => setQueueOpen(false)} />
</div>
)}
</div>
{/* Expanded player */}
{expanded && (
<div className="fixed inset-0 z-40 flex flex-col animate-fade-in">
{/* Background: blurred cover, or a gradient when there's no cover */}
<div className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-br from-accent via-[#0b0d14] to-accent2">
{current.coverUrl && (
<img
src={current.coverUrl}
alt=""
className="h-full w-full scale-125 object-cover opacity-50 blur-3xl"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black to-black" />
</div>
<header className="flex items-center justify-between px-5 py-4">
<button
className="btn-ghost"
onClick={() => setExpanded(false)}
>
<ChevronDown size={16} /> Свернуть
</button>
<div className="text-xs uppercase tracking-widest text-zinc-400">
Сейчас играет
</div>
<a
className="btn-ghost"
href={`/api/tracks/${current.id}/stream?download=1`}
>
<Download size={16} /> Скачать
</a>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-hidden px-5 pb-8 md:flex-row md:items-stretch md:justify-center md:gap-12">
{/* Left: artwork + info */}
<div className="mx-auto w-full max-w-sm shrink-0 md:mx-0 md:my-auto">
<div className="aspect-square overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl">
{current.coverUrl ? (
<img
src={current.coverUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center bg-gradient-to-br from-accent/30 to-accent2/30 text-5xl text-white/40">
AR
</div>
)}
</div>
<div className="mt-5 text-center md:text-left flex flex-col">
<Link
to={`/track/${current.id}`}
onClick={() => setExpanded(false)}
className="text-2xl font-bold tracking-tight text-white hover:underline"
>
{current.title}
</Link>
{current.album && (
<Link
to={`/album/${current.album.id}`}
onClick={() => setExpanded(false)}
className="block text-sm text-zinc-500 hover:text-zinc-300 hover:underline"
>
{current.album.title}
</Link>
)}
<div className="mt-1 flex items-center justify-center gap-2 md:justify-start">
{current.artist && (
<Cover
src={current.artist.imageUrl}
name={current.artist.name}
rounded="rounded-full"
className="h-6 w-6"
/>
)}
<ArtistLinks
artists={current.artists}
fallback={current.artist?.name}
className="text-zinc-300"
onNavigate={() => setExpanded(false)}
/>
</div>
<div className="mt-3 flex items-center justify-center gap-2 md:justify-start border border-white/10 bg-white/5 shadow-2xl rounded-3xl p-1">
{uploader ? (
<>
<Link
to={`/u/${uploader.username}`}
onClick={() => setExpanded(false)}
>
<Cover
src={uploader.avatarUrl}
name={uploader.displayName || uploader.username}
rounded="rounded-full"
className="h-7 w-7"
/>
</Link>
<span className="text-xs text-zinc-500">
Добавил:{" "}
<Link
to={`/u/${uploader.username}`}
onClick={() => setExpanded(false)}
className="link"
>
{uploader.displayName || uploader.username}
</Link>
</span>
</>
) : (
<span className="text-xs text-zinc-500">
Добавил: неизвестно
</span>
)}
</div>
</div>
</div>
{/* Right: controls + tabbed panel */}
<div className="flex min-h-0 w-full max-w-xl flex-1 flex-col">
<div className="card shrink-0 p-3">
<div className="flex items-center gap-3">
<span className="w-10 text-right text-[11px] tabular-nums text-zinc-500">
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="range-track flex-1"
/>
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
{formatTime(duration)}
</span>
</div>
<div className="mt-3 flex items-center justify-center gap-4">
<button
className={cx(
shuffle
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={toggleShuffle}
title="Перемешать"
>
<Shuffle size={20} />
</button>
<button
className="text-zinc-200 hover:text-white"
onClick={prev}
title="Назад"
>
<SkipBack size={26} />
</button>
<button
className="grid h-14 w-14 place-items-center rounded-full bg-white text-black transition hover:scale-105"
onClick={toggle}
>
{isPlaying ? (
<Pause size={22} />
) : (
<Play size={22} className="translate-x-[1px]" />
)}
</button>
<button
className="text-zinc-200 hover:text-white"
onClick={next}
title="Вперёд"
>
<SkipForward size={26} />
</button>
<button
className={cx(
repeat !== "off"
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={cycleRepeat}
title="Повтор"
>
{repeat === "one" ? (
<Repeat1 size={20} />
) : (
<Repeat size={20} />
)}
</button>
</div>
<div className="mt-3 flex items-center gap-2 px-2">
<Volume2 size={16} className="text-zinc-500" />
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="range-track w-32"
/>
<button
className="btn-ghost ml-auto"
onClick={() => setAddOpen(true)}
>
<PlusCircle size={15} /> В плейлист
</button>
</div>
</div>
<div className="card mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 gap-1 border-b border-white/10 p-2">
<TabButton
active={tab === "queue"}
onClick={() => setTab("queue")}
icon={<ListMusic size={14} />}
>
Очередь
</TabButton>
<TabButton
active={tab === "lyrics"}
onClick={() => setTab("lyrics")}
icon={<Mic2 size={14} />}
>
Текст
</TabButton>
<TabButton
active={tab === "comments"}
onClick={() => setTab("comments")}
icon={<MessageSquare size={14} />}
>
Комментарии
</TabButton>
</div>
<div className="min-h-0 flex-1">
{tab === "queue" && (
<div className="h-full min-h-0 overflow-hidden">
<QueuePanel />
</div>
)}
{tab === "lyrics" && (
<div className="h-full min-h-0 overflow-y-auto">
<LyricsView trackId={current.id} />
</div>
)}
{tab === "comments" && (
<div className="h-full min-h-0 overflow-y-auto p-3">
<Comments trackId={current.id} compact />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
<AddToPlaylistModal
track={current}
open={addOpen}
onClose={() => setAddOpen(false)}
/>
</>
)}
</>
);
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={cx(
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition",
active
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)}
>
{icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { Link } from "react-router-dom";
import type { Playlist } from "../lib/types";
import { Cover } from "./Cover";
export function PlaylistCard({ playlist }: { playlist: Playlist }) {
return (
<Link
to={`/playlist/${playlist.id}`}
className="card group p-3 transition hover:bg-white/[0.07]"
>
<Cover src={playlist.coverUrl} name={playlist.title} className="aspect-square w-full" />
<div className="mt-3">
<div className="truncate text-sm font-medium text-white">{playlist.title}</div>
<div className="truncate text-xs text-zinc-500">
{playlist.owner?.displayName || playlist.owner?.username || "Плейлист"} ·{" "}
{playlist.trackCount} треков
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,121 @@
import { ChevronDown, ChevronUp, Play, PlusCircle, Trash2, X } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { formatDuration } from "../lib/format";
import type { Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Cover } from "./Cover";
export function QueuePanel({ onClose }: { onClose?: () => void }) {
const queue = usePlayer((s) => s.queue);
const index = usePlayer((s) => s.index);
const isPlaying = usePlayer((s) => s.isPlaying);
const removeAt = usePlayer((s) => s.removeAt);
const move = usePlayer((s) => s.move);
const goTo = usePlayer((s) => s.goTo);
const toggle = usePlayer((s) => s.toggle);
const [addTrack, setAddTrack] = useState<Track | null>(null);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs uppercase tracking-wide text-zinc-400">Очередь · {queue.length}</span>
{onClose && (
<button onClick={onClose} className="text-zinc-400 hover:text-white">
<X size={15} />
</button>
)}
</div>
{queue.length === 0 ? (
<p className="px-3 py-6 text-sm text-zinc-500">Очередь пуста.</p>
) : (
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-1 pb-2">
{queue.map((track, i) => {
const active = i === index;
return (
<div
key={`${track.id}-${i}`}
className={
"group flex items-center gap-2 rounded-xl px-2 py-1.5 text-sm transition " +
(active ? "bg-white/10" : "hover:bg-white/5")
}
>
<button
className="relative h-9 w-9 shrink-0 overflow-hidden rounded-lg"
onClick={() => (active ? toggle() : goTo(i))}
title="Играть"
>
<Cover src={track.coverUrl} name={track.title} className="h-9 w-9" />
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
<Play size={13} className="text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
<Link
to={`/track/${track.id}`}
onClick={onClose}
className={"block truncate hover:underline " + (active ? "text-white" : "text-zinc-200")}
>
{track.title}
</Link>
<div className="truncate text-[11px] text-zinc-500">
<ArtistLinks
artists={track.artists}
fallback={track.artist?.name}
linkClassName="hover:text-zinc-300"
onNavigate={onClose}
/>
</div>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-zinc-500">
{formatDuration(track.durationMs)}
</span>
<div className="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100">
<button
className="p-1 text-zinc-400 hover:text-white"
onClick={() => setAddTrack(track)}
title="Добавить в плейлист"
>
<PlusCircle size={13} />
</button>
<button
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
disabled={i === 0}
onClick={() => move(i, i - 1)}
title="Выше"
>
<ChevronUp size={14} />
</button>
<button
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
disabled={i === queue.length - 1}
onClick={() => move(i, i + 1)}
title="Ниже"
>
<ChevronDown size={14} />
</button>
<button
className="p-1 text-zinc-400 hover:text-red-300"
onClick={() => removeAt(i)}
title="Убрать из очереди"
>
<Trash2 size={13} />
</button>
</div>
</div>
);
})}
</div>
)}
<AddToPlaylistModal track={addTrack} open={Boolean(addTrack)} onClose={() => setAddTrack(null)} />
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Check, Share2 } from "lucide-react";
import { useState } from "react";
export function ShareButton({ path, label = "Поделиться" }: { path: string; label?: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
} catch {
window.prompt("Скопируйте ссылку:", url);
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<button className="btn-ghost" onClick={copy}>
{copied ? <Check size={15} /> : <Share2 size={15} />}
{copied ? "Ссылка скопирована" : label}
</button>
);
}

View File

@@ -0,0 +1,165 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Heart, ListPlus, MoreHorizontal, Play, PlusCircle } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import type { Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Cover } from "./Cover";
export function TrackRow({
track,
list,
index,
showCover = true,
showAlbum = true,
showUploader = false,
initialLiked = false,
right,
}: {
track: Track;
list?: Track[];
index?: number;
showCover?: boolean;
showAlbum?: boolean;
showUploader?: boolean;
initialLiked?: boolean;
right?: React.ReactNode;
}) {
const playNow = usePlayer((s) => s.playNow);
const playNext = usePlayer((s) => s.playNext);
const addToQueue = usePlayer((s) => s.addToQueue);
const current = usePlayer((s) => s.queue[s.index] ?? null);
const isPlaying = usePlayer((s) => s.isPlaying);
const qc = useQueryClient();
const [liked, setLiked] = useState(initialLiked);
const [modalOpen, setModalOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const like = useMutation({
mutationFn: () => (liked ? tracksApi.unlike(track.id) : tracksApi.like(track.id)),
onSuccess: () => {
setLiked((v) => !v);
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
const active = current?.id === track.id;
return (
<div
className={cx(
"group flex items-center gap-3 rounded-xl px-2 py-2 transition hover:bg-white/[0.06]",
active && "bg-white/[0.08]",
)}
>
{index !== undefined && (
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-zinc-500">{index + 1}</span>
)}
<button
className="relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg"
onClick={() => playNow(track, list)}
title="Играть"
>
{showCover ? (
<Cover src={track.coverUrl} name={track.title} className="h-10 w-10" />
) : (
<span className="h-10 w-10 rounded-lg bg-white/5" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
<Play size={15} className="text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
<Link to={`/track/${track.id}`} className="block truncate text-sm font-medium text-white hover:underline">
{track.title}
</Link>
<div className="truncate text-xs text-zinc-500">
<ArtistLinks
artists={track.artists}
fallback={track.artist?.name}
linkClassName="hover:text-zinc-300"
/>
{showUploader && track.uploadedBy && (
<span className="text-zinc-600"> · загрузил {track.uploadedBy.username}</span>
)}
</div>
</div>
{showAlbum && track.album && (
<Link
to={`/album/${track.album.id}`}
className="hidden max-w-[180px] truncate text-xs text-zinc-500 hover:text-zinc-300 lg:block"
>
{track.album.title}
</Link>
)}
{right}
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-zinc-500">
{formatDuration(track.durationMs)}
</span>
<button
className={cx("shrink-0 p-1.5", liked ? "text-rose-400" : "text-zinc-500 hover:text-white")}
onClick={() => like.mutate()}
title="В избранное"
>
<Heart size={15} fill={liked ? "currentColor" : "none"} />
</button>
<div className="relative shrink-0">
<button
className="p-1.5 text-zinc-500 hover:text-white"
onClick={() => setMenuOpen((v) => !v)}
title="Ещё"
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-8 z-20 w-52 rounded-xl border border-white/10 bg-[#0d0f16]/95 p-1 shadow-2xl backdrop-blur-xl">
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
addToQueue(track);
setMenuOpen(false);
}}
>
<ListPlus size={14} /> В очередь
</button>
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
playNext(track);
setMenuOpen(false);
}}
>
<Play size={14} /> Играть следующим
</button>
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
setModalOpen(true);
setMenuOpen(false);
}}
>
<PlusCircle size={14} /> Добавить в плейлист
</button>
</div>
</>
)}
</div>
<AddToPlaylistModal track={track} open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}

82
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg text-zinc-100 font-sans antialiased;
background-image:
radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
radial-gradient(900px 500px at -10% 20%, rgba(34, 211, 238, 0.12), transparent 55%);
background-attachment: fixed;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
border-radius: 999px;
}
@layer components {
.glass {
@apply bg-white/[0.045] border border-white/10 backdrop-blur-xl;
}
.glass-strong {
@apply bg-black/40 border border-white/10 backdrop-blur-2xl;
}
.card {
@apply glass rounded-2xl;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition
disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70;
}
.btn-primary {
@apply btn bg-gradient-to-r from-accent to-accent2 text-black hover:brightness-110;
}
.btn-ghost {
@apply btn bg-white/5 hover:bg-white/10 text-zinc-100 border border-white/10;
}
.btn-danger {
@apply btn bg-red-500/15 hover:bg-red-500/25 text-red-200 border border-red-400/20;
}
.input {
@apply w-full rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-sm text-zinc-100
placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent/60;
}
.label {
@apply mb-1.5 block text-xs font-medium uppercase tracking-wide text-zinc-400;
}
.chip {
@apply inline-flex items-center rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-zinc-300;
}
.link {
@apply text-zinc-300 hover:text-white transition-colors;
}
.section-title {
@apply text-lg font-semibold tracking-tight text-white;
}
}
.range-track input[type="range"] {
@apply h-1 w-full cursor-pointer appearance-none rounded-full bg-white/15;
}
input[type="range"]::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white;
}

209
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,209 @@
import axios from "axios";
import type {
Album,
Artist,
Comment,
Lyrics,
Playlist,
PlaylistEntry,
PublicUser,
SelfUser,
Track,
UploadStatus,
} from "./types";
export const api = axios.create({
baseURL: "/api",
withCredentials: true,
});
export interface SearchResults {
tracks: Track[];
artists: Artist[];
albums: Album[];
playlists: Playlist[];
users: PublicUser[];
}
export const authApi = {
session: () => api.get<{ user: SelfUser | null }>("/auth/session").then((r) => r.data.user),
login: (login: string, password: string) =>
api.post<{ user: SelfUser }>("/auth/login", { login, password }).then((r) => r.data.user),
logout: () => api.post("/auth/logout").then((r) => r.data),
};
export const meApi = {
update: (data: Partial<Pick<SelfUser, "displayName" | "bio" | "avatarUrl">>) =>
api.patch<{ user: SelfUser }>("/me", data).then((r) => r.data.user),
changePassword: (currentPassword: string, newPassword: string) =>
api.post("/me/password", { currentPassword, newPassword }).then((r) => r.data),
uploadAvatar: (file: File) => {
const form = new FormData();
form.append("avatar", file);
return api.post<{ user: SelfUser }>("/me/avatar", form).then((r) => r.data.user);
},
};
export const tracksApi = {
random: (limit = 20) =>
api.get<{ items: Track[] }>("/tracks/random", { params: { limit } }).then((r) => r.data.items),
list: (params: { q?: string; artistId?: string; albumId?: string; limit?: number; offset?: number } = {}) =>
api.get<{ items: Track[]; total: number }>("/tracks", { params }).then((r) => r.data),
get: (id: string) =>
api
.get<{ track: Track; lyrics: Lyrics; liked: boolean; canEdit: boolean }>(`/tracks/${id}`)
.then((r) => r.data),
update: (
id: string,
data: {
title?: string;
artistId?: string | null;
albumId?: string | null;
trackNumber?: number | null;
discNumber?: number | null;
releaseDate?: string | null;
coverUrl?: string | null;
durationMs?: number | null;
lyricsPlain?: string | null;
artists?: string[];
},
) => api.patch<{ track: Track }>(`/tracks/${id}`, data).then((r) => r.data.track),
uploadCover: (id: string, file: File, applyToAlbum = false) => {
const form = new FormData();
form.append("cover", file);
return api
.post<{ track: Track }>(`/tracks/${id}/cover`, form, {
params: applyToAlbum ? { album: "1" } : {},
})
.then((r) => r.data.track);
},
remove: (id: string) => api.delete(`/tracks/${id}`).then((r) => r.data),
like: (id: string) => api.post(`/tracks/${id}/like`).then((r) => r.data),
unlike: (id: string) => api.delete(`/tracks/${id}/like`).then((r) => r.data),
comments: (id: string) =>
api.get<{ comments: Comment[]; total: number }>(`/tracks/${id}/comments`).then((r) => r.data),
addComment: (id: string, body: string, parentId?: string | null) =>
api.post<{ comment: Comment }>(`/tracks/${id}/comments`, { body, parentId }).then((r) => r.data),
deleteComment: (commentId: string) => api.delete(`/comments/${commentId}`).then((r) => r.data),
};
export const uploadsApi = {
mine: () => api.get<UploadStatus>("/uploads/mine").then((r) => r.data),
upload: (file: File, onProgress?: (p: number) => void) => {
const form = new FormData();
form.append("file", file);
return api
.post<{ track: Track }>("/uploads", form, {
onUploadProgress: (e) => {
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
},
})
.then((r) => r.data.track);
},
remove: (id: string) => api.delete(`/uploads/${id}`).then((r) => r.data),
retry: (id: string) => api.post(`/uploads/${id}/retry`).then((r) => r.data),
};
export const artistsApi = {
get: (id: string) =>
api
.get<{
artist: Artist;
following: boolean;
albums: Album[];
canEdit: boolean;
}>(`/artists/${id}`)
.then((r) => r.data),
tracks: (id: string) => api.get<{ items: Track[] }>(`/artists/${id}/tracks`).then((r) => r.data.items),
follow: (id: string) => api.post(`/artists/${id}/follow`).then((r) => r.data),
unfollow: (id: string) => api.delete(`/artists/${id}/follow`).then((r) => r.data),
};
export const albumsApi = {
get: (id: string) =>
api
.get<{ album: Album; tracks: Track[]; canEdit: boolean }>(`/albums/${id}`)
.then((r) => r.data),
};
export const playlistsApi = {
mine: () => api.get<{ owned: Playlist[] }>("/me/playlists").then((r) => r.data.owned),
get: (id: string) =>
api
.get<{
playlist: Playlist;
entries: PlaylistEntry[];
collaborators: { userId: string; canEdit: boolean }[];
inLibrary: boolean;
libraryMode: "reference" | "copy" | null;
}>(`/playlists/${id}`)
.then((r) => r.data),
create: (data: Partial<Playlist> & { title: string }) =>
api.post<{ playlist: Playlist }>("/playlists", data).then((r) => r.data.playlist),
update: (id: string, data: Partial<Playlist>) =>
api.patch<{ playlist: Playlist }>(`/playlists/${id}`, data).then((r) => r.data.playlist),
remove: (id: string) => api.delete(`/playlists/${id}`).then((r) => r.data),
addTrack: (id: string, trackId: string, position?: number) =>
api.post(`/playlists/${id}/tracks`, { trackId, position }).then((r) => r.data),
removeTrack: (id: string, entryId: string) =>
api.delete(`/playlists/${id}/tracks/${entryId}`).then((r) => r.data),
reorder: (id: string, entryIds: string[]) =>
api.put(`/playlists/${id}/order`, { entryIds }).then((r) => r.data),
addToLibrary: (id: string, mode: "reference" | "copy") =>
api.post(`/playlists/${id}/library`, { mode }).then((r) => r.data),
removeFromLibrary: (id: string) => api.delete(`/playlists/${id}/library`).then((r) => r.data),
};
export const libraryApi = {
get: () =>
api
.get<{
ownedPlaylists: Playlist[];
savedPlaylists: {
mode: string;
canEdit: boolean;
originalPlaylistId: string;
playlist: Playlist;
}[];
followedArtists: Artist[];
likedTracks: Track[];
}>("/library")
.then((r) => r.data),
};
export const searchApi = {
query: (q: string, type = "all") =>
api.get<SearchResults>("/search", { params: { q, type } }).then((r) => r.data),
};
export const usersApi = {
get: (username: string) =>
api
.get<{ user: PublicUser; playlists: Playlist[]; trackCount: number }>(`/users/${username}`)
.then((r) => r.data),
};
export const adminApi = {
stats: () => api.get("/admin/stats").then((r) => r.data),
users: () => api.get<{ items: SelfUser[] }>("/admin/users").then((r) => r.data.items),
createUser: (data: {
email: string;
username: string;
password: string;
displayName?: string;
permissions?: Partial<SelfUser["permissions"]>;
}) => api.post<{ user: SelfUser }>("/admin/users", data).then((r) => r.data.user),
updateUser: (id: string, data: Record<string, unknown>) =>
api.patch<{ user: SelfUser }>(`/admin/users/${id}`, data).then((r) => r.data.user),
resetPassword: (id: string, password: string) =>
api.post(`/admin/users/${id}/password`, { password }).then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
moderation: () =>
api.get<{ items: Track[]; total: number }>("/admin/moderation").then((r) => r.data),
approve: (id: string) => api.post(`/admin/tracks/${id}/approve`).then((r) => r.data),
reject: (id: string, reason?: string) =>
api.post(`/admin/tracks/${id}/reject`, { reason }).then((r) => r.data),
playlists: () => api.get<{ items: Playlist[]; total: number }>("/admin/playlists").then((r) => r.data),
tracks: (params: { status?: string; q?: string } = {}) =>
api.get<{ items: Track[]; total: number }>("/admin/tracks", { params }).then((r) => r.data),
};

View File

@@ -0,0 +1,35 @@
export function formatDuration(ms: number | null | undefined): string {
if (!ms || ms <= 0) return "--:--";
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function formatDate(value: string | null | undefined): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" });
}
export function initials(name: string | null | undefined): string {
if (!name) return "?";
return name
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export function cx(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,16 @@
import type { Permissions, SelfUser } from "./types";
export function hasPerm(
user: Pick<SelfUser, "isSuperAdmin" | "permissions"> | null | undefined,
key: keyof Permissions,
): boolean {
if (!user) return false;
if (user.isSuperAdmin) return true;
return Boolean(user.permissions?.[key]);
}
export function canAccessAdmin(user: Pick<SelfUser, "isSuperAdmin" | "permissions">): boolean {
if (user.isSuperAdmin) return true;
const p = user.permissions;
return Boolean(p && (p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists));
}

130
apps/web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,130 @@
export interface Permissions {
canCreatePlaylists: boolean;
canUploadTracks: boolean;
canAutoApprove: boolean;
canModerate: boolean;
canManageUsers: boolean;
canManageLibrary: boolean;
canViewAllPlaylists: boolean;
}
export interface PublicUser {
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
createdAt: string;
}
export interface SelfUser extends PublicUser {
email: string;
isSuperAdmin: boolean;
permissions: Permissions;
}
export interface ArtistLite {
id: string;
name: string;
imageUrl: string | null;
}
export interface TrackArtistCredit {
id: string;
name: string;
imageUrl: string | null;
role: string;
}
export interface AlbumLite {
id: string;
title: string;
coverUrl: string | null;
}
export interface Track {
id: string;
title: string;
durationMs: number | null;
trackNumber: number | null;
discNumber: number | null;
releaseDate: string | null;
status: "pending" | "approved" | "rejected";
rejectionReason: string | null;
coverUrl: string | null;
playCount: number;
hasLyrics: boolean;
lyricsSource: string | null;
createdAt: string;
approvedAt: string | null;
artist: ArtistLite | null;
artists: TrackArtistCredit[];
album: AlbumLite | null;
uploadedBy?: PublicUser | null;
}
export interface Artist {
id: string;
name: string;
sortName: string | null;
description: string | null;
imageUrl: string | null;
country: string | null;
mbArtistId: string | null;
followersCount: number;
trackCount?: number;
}
export interface Album {
id: string;
title: string;
releaseDate: string | null;
coverUrl: string | null;
artist: ArtistLite | null;
}
export interface Playlist {
id: string;
title: string;
description: string | null;
coverUrl: string | null;
visibility: "public" | "unlisted" | "private";
isShareable: boolean;
allowCollaboratorEdit: boolean;
createdAt: string;
updatedAt: string;
owner: PublicUser | null;
trackCount: number;
canEdit: boolean;
isOwner: boolean;
}
export interface PlaylistEntry {
entryId: string;
position: number;
addedBy: PublicUser | null;
track: Track | null;
}
export interface Lyrics {
plain: string | null;
synced: { timeMs: number; text: string }[] | null;
source: string | null;
}
export interface Comment {
id: string;
trackId: string;
parentId: string | null;
body: string | null;
deleted: boolean;
createdAt: string;
updatedAt: string;
user: PublicUser | null;
replies: Comment[];
}
export interface UploadStatus {
pending: Track[];
rejected: Track[];
}

29
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./index.css";
import { AuthProvider } from "./store/auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 15_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,190 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { LogOut } from "lucide-react";
import { useRef, useState } from "react";
import { meApi } from "../lib/api";
import { hasPerm } from "../lib/permissions";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
export function Account() {
const { user, setUser, logout } = useAuth();
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState({
displayName: user?.displayName ?? "",
bio: user?.bio ?? "",
});
const [pw, setPw] = useState({ current: "", next: "" });
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const saveProfile = useMutation({
mutationFn: () => meApi.update({ displayName: profile.displayName, bio: profile.bio }),
onSuccess: (u) => {
setUser(u);
setMsg("Профиль сохранён");
setErr("");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
const changePw = useMutation({
mutationFn: () => meApi.changePassword(pw.current, pw.next),
onSuccess: () => {
setPw({ current: "", next: "" });
setMsg("Пароль изменён");
setErr("");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
const uploadAvatar = useMutation({
mutationFn: (file: File) => meApi.uploadAvatar(file),
onSuccess: (u) => {
setUser(u);
setMsg("Аватар обновлён");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
if (!user) return null;
const permList: { key: keyof typeof user.permissions; label: string }[] = [
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
{ key: "canUploadTracks", label: "Загрузка треков" },
{ key: "canAutoApprove", label: "Автопринятие без модерации" },
{ key: "canModerate", label: "Модерация" },
{ key: "canManageUsers", label: "Управление пользователями" },
{ key: "canManageLibrary", label: "Управление библиотекой" },
{ key: "canViewAllPlaylists", label: "Просмотр всех плейлистов" },
];
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<h1 className="mb-5 text-2xl font-bold tracking-tight text-white">Аккаунт</h1>
{(msg || err) && (
<div
className={
"mb-4 rounded-xl px-3 py-2 text-sm " +
(err
? "border border-red-400/20 bg-red-500/10 text-red-200"
: "border border-emerald-400/20 bg-emerald-500/10 text-emerald-200")
}
>
{err || msg}
</div>
)}
<div className="card mb-5 flex flex-col items-center gap-4 p-6 sm:flex-row sm:items-start">
<button className="relative" onClick={() => fileRef.current?.click()} title="Сменить аватар">
<Cover src={user.avatarUrl} name={user.displayName || user.username} rounded="rounded-full" className="h-24 w-24" />
<span className="absolute -bottom-1 right-0 chip bg-black/70">изменить</span>
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadAvatar.mutate(f);
e.target.value = "";
}}
/>
<div className="flex-1 text-center sm:text-left">
<div className="text-xl font-semibold text-white">{user.displayName || user.username}</div>
<div className="text-sm text-zinc-500">@{user.username}</div>
<div className="mt-2 flex flex-wrap justify-center gap-1 sm:justify-start">
{user.isSuperAdmin && <span className="chip text-accent">супер-админ</span>}
{permList
.filter((p) => hasPerm(user, p.key))
.map((p) => (
<span key={p.key} className="chip">
{p.label}
</span>
))}
</div>
</div>
<button className="btn-danger" onClick={logout}>
<LogOut size={15} /> Выйти
</button>
</div>
<div className="grid gap-5 md:grid-cols-2">
<section className="card p-5">
<h2 className="section-title mb-4">Профиль</h2>
<div className="space-y-3">
<div>
<label className="label">Отображаемое имя</label>
<input
className="input"
value={profile.displayName}
onChange={(e) => setProfile({ ...profile, displayName: e.target.value })}
/>
</div>
<div>
<label className="label">О себе</label>
<textarea
className="input min-h-24"
value={profile.bio}
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
/>
</div>
<div>
<label className="label">Почта</label>
<input className="input opacity-60" value={user.email} disabled />
</div>
<button className="btn-primary" onClick={() => saveProfile.mutate()} disabled={saveProfile.isPending}>
Сохранить
</button>
</div>
</section>
<section className="card p-5">
<h2 className="section-title mb-4">Смена пароля</h2>
<div className="space-y-3">
<div>
<label className="label">Текущий пароль</label>
<input
className="input"
type="password"
value={pw.current}
onChange={(e) => setPw({ ...pw, current: e.target.value })}
/>
</div>
<div>
<label className="label">Новый пароль</label>
<input
className="input"
type="password"
value={pw.next}
onChange={(e) => setPw({ ...pw, next: e.target.value })}
/>
</div>
<button
className="btn-primary"
onClick={() => changePw.mutate()}
disabled={changePw.isPending || !pw.current || pw.next.length < 6}
>
Изменить пароль
</button>
<p className="text-xs text-zinc-600">
При смене пароля все другие сессии завершаются автоматически.
</p>
</div>
</section>
</div>
<div className="mt-5 card p-5">
<h2 className="section-title mb-3">Ссылки</h2>
<div className="flex flex-wrap gap-2">
<a className="btn-ghost" href={`/u/${user.username}`}>
Мой публичный профиль
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CheckCircle2, Clock, RefreshCw, Trash2, UploadCloud } from "lucide-react";
import { useRef, useState } from "react";
import { Link } from "react-router-dom";
import { uploadsApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import { hasPerm } from "../lib/permissions";
import type { Track } from "../lib/types";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
export function AddTrack() {
const { user } = useAuth();
const qc = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState("");
const [duplicate, setDuplicate] = useState<{ id: string; title: string } | null>(null);
const [success, setSuccess] = useState("");
const canUpload = hasPerm(user, "canUploadTracks");
const autoApprove = hasPerm(user, "canAutoApprove");
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["uploads-mine"],
queryFn: uploadsApi.mine,
enabled: canUpload,
refetchInterval: 8000,
});
const upload = useMutation({
mutationFn: (files: File[]) => {
const uploadNext = (i: number): Promise<void> => {
if (i >= files.length) return Promise.resolve();
return uploadsApi
.upload(files[i], setProgress)
.then(() => {
setSuccess(`«${files[i].name}» загружен и отправлен на обработку`);
return uploadNext(i + 1);
});
};
return uploadNext(0);
},
onSuccess: () => {
setProgress(null);
void qc.invalidateQueries({ queryKey: ["uploads-mine"] });
},
onError: (err: any) => {
setProgress(null);
const details = err?.response?.data?.details;
if (details?.trackId) {
setDuplicate({ id: details.trackId, title: details.title });
setError("");
} else {
setDuplicate(null);
setError(err?.response?.data?.error ?? "Ошибка загрузки");
}
},
});
const remove = useMutation({
mutationFn: (id: string) => uploadsApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
});
const retry = useMutation({
mutationFn: (id: string) => uploadsApi.retry(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
});
if (!canUpload) {
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<div className="card p-8 text-center">
<AlertTriangle className="mx-auto mb-3 text-amber-400" />
<h2 className="text-lg font-semibold text-white">Загрузка недоступна</h2>
<p className="mt-2 text-sm text-zinc-500">
У вашей учётной записи нет разрешения на загрузку треков. Обратитесь к администратору.
</p>
</div>
</div>
);
}
const pending = data?.pending ?? [];
const rejected = data?.rejected ?? [];
const handleFiles = (files: FileList | null) => {
if (!files?.length) return;
setError("");
setSuccess("");
setDuplicate(null);
upload.mutate(Array.from(files));
};
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Добавить музыку</h1>
<p className="mb-5 text-sm text-zinc-500">
Файл загрузится, определится дубликат, затем из интернета подтянутся название, исполнитель,
обложка, альбом и текст песни. После этого трек уйдёт на модерацию.
{autoApprove && " Вам разрешено автопринятие — треки попадут в библиотеку сразу."}
</p>
<div
className="card mb-6 grid cursor-pointer place-items-center border-dashed p-8 text-center transition hover:bg-white/[0.06]"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
}}
>
<UploadCloud className="mb-3 text-accent" size={32} />
<p className="text-sm text-zinc-300">Перетащите аудиофайлы сюда или нажмите для выбора</p>
<p className="mt-1 text-xs text-zinc-600">MP3, FLAC, WAV, OGG, M4A, AAC, OPUS · до 150 МБ</p>
<input
ref={inputRef}
type="file"
accept="audio/*"
multiple
className="hidden"
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
</div>
{progress !== null && (
<div className="mb-4">
<div className="mb-1 flex justify-between text-xs text-zinc-400">
<span>Загрузка...</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-gradient-to-r from-accent to-accent2 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{error && (
<div className="mb-4 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{duplicate && (
<div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-100">
Дубликат: «{duplicate.title}» уже есть в библиотеке.{" "}
<Link to={`/track/${duplicate.id}`} className="underline hover:text-white">
Открыть трек →
</Link>
</div>
)}
{success && (
<div className="mb-4 rounded-xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
{success}
</div>
)}
<section className="mb-8">
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title flex items-center gap-2">
<Clock size={16} /> На обработке / модерации
</h2>
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw size={14} className={isFetching ? "animate-spin" : ""} /> Обновить
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : pending.length ? (
<div className="space-y-2">
{pending.map((t) => (
<StatusRow key={t.id} track={t} kind="pending" onRemove={() => remove.mutate(t.id)} />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Нет треков в обработке.</p>
)}
</section>
<section>
<h2 className="section-title mb-3 flex items-center gap-2 text-red-200">
<AlertTriangle size={16} /> Отклонённые
</h2>
{rejected.length ? (
<div className="space-y-2">
{rejected.map((t) => (
<StatusRow
key={t.id}
track={t}
kind="rejected"
onRemove={() => remove.mutate(t.id)}
onRetry={() => retry.mutate(t.id)}
/>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Отклонённых треков нет.</p>
)}
</section>
</div>
);
}
function StatusRow({
track,
kind,
onRemove,
onRetry,
}: {
track: Track;
kind: "pending" | "rejected";
onRemove: () => void;
onRetry?: () => void;
}) {
const processing = (track as any).status === "pending";
return (
<div className="card flex items-center gap-3 p-3">
<Cover src={track.coverUrl} name={track.title} className="h-12 w-12 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{track.title}</div>
<div className="truncate text-xs text-zinc-500">
{track.artist?.name ?? "Исполнитель определяется..."}
{track.durationMs ? ` · ${formatDuration(track.durationMs)}` : ""}
</div>
{kind === "rejected" && track.rejectionReason && (
<div className="mt-1 text-xs text-red-300">Причина: {track.rejectionReason}</div>
)}
</div>
<span
className={cx(
"chip hidden sm:inline-flex",
kind === "rejected" ? "text-red-200" : "text-amber-200",
)}
>
{kind === "rejected" ? (
<>
<AlertTriangle size={12} className="mr-1" /> Отклонён
</>
) : processing ? (
<>
<Clock size={12} className="mr-1" /> Обработка
</>
) : (
<>
<CheckCircle2 size={12} className="mr-1" /> На модерации
</>
)}
</span>
{onRetry && (
<button className="btn-ghost" onClick={onRetry} title="Повторить обработку">
<RefreshCw size={14} />
</button>
)}
<button className="btn-danger" onClick={onRemove} title="Удалить">
<Trash2 size={14} />
</button>
</div>
);
}

View File

@@ -0,0 +1,652 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BarChart3,
Check,
Library,
ListMusic,
Pencil,
Plus,
RefreshCw,
Shield,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import { useState, useRef } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { adminApi, playlistsApi, tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import { canAccessAdmin, hasPerm } from "../lib/permissions";
import type { SelfUser, Track } from "../lib/types";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
import { Modal } from "../components/Modal";
import { TrackRow } from "../components/TrackRow";
type Section = "overview" | "moderation" | "users" | "playlists" | "library";
export function Admin() {
const { user } = useAuth();
const { section } = useParams();
const navigate = useNavigate();
if (!user || !canAccessAdmin(user)) return <Navigate to="/" replace />;
const tabs: { id: Section; label: string; icon: any; show: boolean }[] = [
{ id: "overview", label: "Обзор", icon: BarChart3, show: true },
{ id: "moderation", label: "Модерация", icon: Shield, show: hasPerm(user, "canModerate") },
{ id: "library", label: "Библиотека", icon: Library, show: hasPerm(user, "canManageLibrary") },
{ id: "users", label: "Пользователи", icon: Users, show: hasPerm(user, "canManageUsers") },
{ id: "playlists", label: "Плейлисты", icon: ListMusic, show: hasPerm(user, "canViewAllPlaylists") },
];
const visible = tabs.filter((t) => t.show);
const current = (visible.find((t) => t.id === section)?.id ?? "overview") as Section;
return (
<div className="mx-auto max-w-6xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Админ-панель</h1>
<p className="mb-5 text-sm text-zinc-500">
Управление модерацией, пользователями, разрешениями и всей библиотекой сервиса.
</p>
<div className="mb-6 flex flex-wrap gap-2">
{visible.map((t) => (
<button
key={t.id}
onClick={() => navigate(`/admin/${t.id}`)}
className={cx(
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm transition",
current === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
<t.icon size={15} /> {t.label}
</button>
))}
</div>
{current === "overview" && <Overview />}
{current === "moderation" && <Moderation />}
{current === "library" && <LibraryAdmin />}
{current === "users" && <UsersAdmin currentUser={user} />}
{current === "playlists" && <PlaylistsAdmin />}
</div>
);
}
function Overview() {
const { data } = useQuery({ queryKey: ["admin-stats"], queryFn: adminApi.stats });
const cards = [
{ label: "Всего треков", value: data?.tracks.total, sub: `${data?.tracks.approved ?? 0} одобрено` },
{ label: "На модерации", value: data?.tracks.pending, sub: "ожидают решения" },
{ label: "Отклонено", value: data?.tracks.rejected, sub: "не в библиотеке" },
{ label: "Пользователи", value: data?.users, sub: "аккаунтов" },
{ label: "Плейлисты", value: data?.playlists, sub: "всего" },
{ label: "Артисты", value: data?.artists, sub: "в базе" },
{ label: "Комментарии", value: data?.comments, sub: "всего" },
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{cards.map((c) => (
<div key={c.label} className="card p-4">
<div className="text-xs uppercase tracking-wide text-zinc-500">{c.label}</div>
<div className="mt-2 text-3xl font-bold text-white">{c.value ?? "—"}</div>
<div className="text-xs text-zinc-500">{c.sub}</div>
</div>
))}
</div>
);
}
function Moderation() {
const qc = useQueryClient();
const [editing, setEditing] = useState<Track | null>(null);
const { data, isLoading, refetch } = useQuery({ queryKey: ["admin-moderation"], queryFn: adminApi.moderation });
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["admin-moderation"] });
void qc.invalidateQueries({ queryKey: ["admin-stats"] });
};
const approve = useMutation({ mutationFn: (id: string) => adminApi.approve(id), onSuccess: invalidate });
const reject = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => adminApi.reject(id, reason),
onSuccess: invalidate,
});
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title">Очередь модерации ({data?.total ?? 0})</h2>
<button className="btn-ghost" onClick={() => refetch()}>
<RefreshCw size={14} /> Обновить
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : data?.items.length ? (
<div className="space-y-2">
{data.items.map((t) => (
<div key={t.id} className="card flex flex-wrap items-center gap-3 p-3">
<Cover src={t.coverUrl} name={t.title} className="h-14 w-14 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{t.title}</div>
<div className="truncate text-xs text-zinc-500">
{t.artist?.name ?? "—"}
{t.album ? ` · ${t.album.title}` : ""}
{t.durationMs ? ` · ${formatDuration(t.durationMs)}` : ""}
</div>
<div className="truncate text-xs text-zinc-600">
загрузил {t.uploadedBy?.username ?? "—"}
</div>
</div>
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => setEditing(t)}>
<Pencil size={14} /> Правки
</button>
<button className="btn-primary" onClick={() => approve.mutate(t.id)}>
<Check size={14} /> Принять
</button>
<button
className="btn-danger"
onClick={() => {
const reason = prompt("Причина отклонения:", "Не соответствует качеству") ?? "";
reject.mutate({ id: t.id, reason });
}}
>
<X size={14} /> Отклонить
</button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Очередь пуста.</p>
)}
<TrackEditModal track={editing} onClose={() => setEditing(null)} onSaved={invalidate} />
</div>
);
}
function LibraryAdmin() {
const qc = useQueryClient();
const [status, setStatus] = useState("approved");
const [q, setQ] = useState("");
const [editing, setEditing] = useState<Track | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["admin-tracks", status, q],
queryFn: () => adminApi.tracks({ status: status || undefined, q }),
});
const remove = useMutation({
mutationFn: (id: string) => tracksApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-tracks"] }),
});
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<select className="input max-w-40" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Все статусы</option>
<option value="approved">Одобренные</option>
<option value="pending">На модерации</option>
<option value="rejected">Отклонённые</option>
</select>
<input
className="input max-w-xs"
placeholder="Название..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : (
<div className="card p-2">
{data?.items.map((t, i) => (
<TrackRow
key={t.id}
track={t}
index={i}
showUploader
right={
<div className="flex items-center">
<button className="p-1 text-zinc-500 hover:text-white" onClick={() => setEditing(t)} title="Правки">
<Pencil size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-red-300"
onClick={() => confirm("Удалить трек?") && remove.mutate(t.id)}
title="Удалить"
>
<Trash2 size={14} />
</button>
</div>
}
/>
))}
{data?.items.length === 0 && <p className="p-4 text-sm text-zinc-500">Нет треков.</p>}
</div>
)}
<TrackEditModal
track={editing}
onClose={() => setEditing(null)}
onSaved={() => void qc.invalidateQueries({ queryKey: ["admin-tracks"] })}
/>
</div>
);
}
function TrackEditModal({
track,
onClose,
onSaved,
}: {
track: Track | null;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState({
title: track?.title ?? "",
releaseDate: track?.releaseDate ?? "",
coverUrl: track?.coverUrl ?? "",
trackNumber: track?.trackNumber ?? 0,
durationMs: track?.durationMs ?? 0,
artistsText: track
? track.artists?.length
? track.artists.map((a) => a.name).join(", ")
: track.artist?.name ?? ""
: "",
});
// reset when track changes
const [lastId, setLastId] = useState(track?.id);
if (track && track.id !== lastId) {
setLastId(track.id);
setForm({
title: track.title,
releaseDate: track.releaseDate ?? "",
coverUrl: track.coverUrl ?? "",
trackNumber: track.trackNumber ?? 0,
durationMs: track.durationMs ?? 0,
artistsText: track.artists?.length
? track.artists.map((a) => a.name).join(", ")
: track.artist?.name ?? "",
});
}
const save = useMutation({
mutationFn: () =>
tracksApi.update(track!.id, {
title: form.title,
releaseDate: form.releaseDate || null,
coverUrl: form.coverUrl || null,
trackNumber: Number(form.trackNumber) || null,
durationMs: Number(form.durationMs) || null,
artists: form.artistsText
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}),
onSuccess: () => {
onSaved();
onClose();
},
});
const coverInputRef = useRef<HTMLInputElement>(null);
const [applyToAlbum, setApplyToAlbum] = useState(false);
const uploadCover = useMutation({
mutationFn: (file: File) => tracksApi.uploadCover(track!.id, file, applyToAlbum),
onSuccess: (updated) => {
setForm((f) => ({ ...f, coverUrl: updated.coverUrl ?? "" }));
onSaved();
},
});
return (
<Modal open={Boolean(track)} onClose={onClose} title="Редактирование метаданных">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="label">Исполнители (через запятую)</label>
<input
className="input"
placeholder="Исполнитель 1, Исполнитель 2"
value={form.artistsText}
onChange={(e) => setForm({ ...form, artistsText: e.target.value })}
/>
<p className="mt-1 text-xs text-zinc-600">
Каждое имя станет отдельным артистом со своей страницей.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Дата выхода</label>
<input
className="input"
placeholder="YYYY-MM-DD"
value={form.releaseDate}
onChange={(e) => setForm({ ...form, releaseDate: e.target.value })}
/>
</div>
<div>
<label className="label">Номер трека</label>
<input
className="input"
type="number"
value={form.trackNumber}
onChange={(e) => setForm({ ...form, trackNumber: Number(e.target.value) })}
/>
</div>
</div>
<div>
<label className="label">Обложка</label>
<div className="flex items-center gap-3">
<Cover src={form.coverUrl} name={form.title} className="h-16 w-16 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<input
className="input"
placeholder="URL обложки"
value={form.coverUrl}
onChange={(e) => setForm({ ...form, coverUrl: e.target.value })}
/>
<div className="flex items-center gap-2">
<button className="btn-ghost" onClick={() => coverInputRef.current?.click()}>
<Upload size={14} /> Загрузить с устройства
</button>
<label className="flex items-center gap-1.5 text-xs text-zinc-400">
<input
type="checkbox"
checked={applyToAlbum}
onChange={(e) => setApplyToAlbum(e.target.checked)}
/>
на весь альбом
</label>
</div>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f && track) uploadCover.mutate(f);
e.target.value = "";
}}
/>
</div>
</div>
</div>
<div>
<label className="label">Длительность (мс)</label>
<input
className="input"
type="number"
value={form.durationMs}
onChange={(e) => setForm({ ...form, durationMs: Number(e.target.value) })}
/>
</div>
<button className="btn-primary w-full" onClick={() => save.mutate()}>
Сохранить
</button>
</div>
</Modal>
);
}
const PERMS: { key: keyof SelfUser["permissions"]; label: string }[] = [
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
{ key: "canUploadTracks", label: "Загрузка треков" },
{ key: "canAutoApprove", label: "Автопринятие" },
{ key: "canModerate", label: "Модерация" },
{ key: "canManageUsers", label: "Пользователи" },
{ key: "canManageLibrary", label: "Библиотека" },
{ key: "canViewAllPlaylists", label: "Все плейлисты" },
];
function UsersAdmin({ currentUser }: { currentUser: SelfUser }) {
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["admin-users"], queryFn: adminApi.users });
const invalidate = () => void qc.invalidateQueries({ queryKey: ["admin-users"] });
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => adminApi.updateUser(id, data),
onSuccess: invalidate,
});
const remove = useMutation({ mutationFn: (id: string) => adminApi.deleteUser(id), onSuccess: invalidate });
const resetPw = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) => adminApi.resetPassword(id, password),
});
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title">Пользователи ({data?.length ?? 0})</h2>
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> Создать пользователя
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : (
<div className="space-y-2">
{data?.map((u) => (
<div key={u.id} className="card p-3">
<div className="flex flex-wrap items-center gap-3">
<Cover src={u.avatarUrl} name={u.displayName || u.username} rounded="rounded-full" className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
{u.displayName || u.username}
{u.isSuperAdmin && <span className="ml-2 chip text-accent">супер</span>}
{u.id === currentUser.id && <span className="ml-2 chip">это вы</span>}
</div>
<div className="truncate text-xs text-zinc-500">
@{u.username} · {u.email}
</div>
</div>
<button className="btn-ghost" onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
<Shield size={14} /> Права
</button>
<button
className="btn-ghost"
onClick={() => {
const password = prompt("Новый пароль (мин. 6):");
if (password && password.length >= 6) resetPw.mutate({ id: u.id, password });
}}
>
Сбросить пароль
</button>
{u.id !== currentUser.id && (
<button
className="btn-danger"
onClick={() => confirm(`Удалить ${u.username}?`) && remove.mutate(u.id)}
>
<Trash2 size={14} />
</button>
)}
</div>
{expanded === u.id && (
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-white/10 pt-3 sm:grid-cols-4">
{PERMS.map((p) => {
const active = u.isSuperAdmin || u.permissions[p.key];
return (
<button
key={p.key}
disabled={u.isSuperAdmin}
onClick={() =>
update.mutate({ id: u.id, data: { permissions: { [p.key]: !active } } })
}
className={cx(
"rounded-xl border px-3 py-2 text-left text-xs transition",
active
? "border-accent/40 bg-accent/15 text-white"
: "border-white/10 bg-white/5 text-zinc-400 hover:bg-white/10",
u.isSuperAdmin && "opacity-60",
)}
>
{p.label}
</button>
);
})}
{currentUser.isSuperAdmin && (
<button
onClick={() => update.mutate({ id: u.id, data: { isSuperAdmin: !u.isSuperAdmin } })}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs text-zinc-300 hover:bg-white/10"
>
Супер-админ: {u.isSuperAdmin ? "да" : "нет"}
</button>
)}
</div>
)}
</div>
))}
</div>
)}
<CreateUserModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={invalidate} />
</div>
);
}
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [form, setForm] = useState({
email: "",
username: "",
password: "",
displayName: "",
});
const [perms, setPerms] = useState<Record<string, boolean>>({
canCreatePlaylists: true,
canUploadTracks: true,
});
const [error, setError] = useState("");
const create = useMutation({
mutationFn: () =>
adminApi.createUser({
...form,
permissions: perms as Partial<SelfUser["permissions"]>,
}),
onSuccess: () => {
onCreated();
onClose();
setForm({ email: "", username: "", password: "", displayName: "" });
},
onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"),
});
return (
<Modal open={open} onClose={onClose} title="Создать пользователя">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Имя пользователя</label>
<input className="input" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<label className="label">Отображаемое имя</label>
<input
className="input"
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
/>
</div>
</div>
<div>
<label className="label">Почта</label>
<input className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div>
<label className="label">Пароль</label>
<input
className="input"
type="text"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div>
<label className="label">Разрешения</label>
<div className="grid grid-cols-2 gap-2">
{PERMS.map((p) => (
<label key={p.key} className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={Boolean(perms[p.key])}
onChange={(e) => setPerms({ ...perms, [p.key]: e.target.checked })}
/>
{p.label}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red-300">{error}</p>}
<button
className="btn-primary w-full"
onClick={() => create.mutate()}
disabled={!form.email || !form.username || form.password.length < 6}
>
Создать
</button>
</div>
</Modal>
);
}
function PlaylistsAdmin() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["admin-playlists"], queryFn: adminApi.playlists });
const remove = useMutation({
mutationFn: (id: string) => playlistsApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-playlists"] }),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
return (
<div className="space-y-2">
{data?.items.map((p) => (
<div key={p.id} className="card flex flex-wrap items-center gap-3 p-3">
<Cover src={p.coverUrl} name={p.title} className="h-12 w-12 shrink-0" />
<div className="min-w-0 flex-1">
<a href={`/playlist/${p.id}`} className="truncate text-sm font-medium text-white hover:underline">
{p.title}
</a>
<div className="truncate text-xs text-zinc-500">
{p.owner?.username ?? "—"} · {p.trackCount} треков ·{" "}
{p.visibility === "public" ? "публичный" : p.visibility === "unlisted" ? "по ссылке" : "приватный"}
</div>
</div>
<button className="btn-ghost" onClick={() => (window.location.href = `/playlist/${p.id}`)}>
Открыть
</button>
<button
className="btn-danger"
onClick={() => confirm("Удалить плейлист?") && remove.mutate(p.id)}
>
<Trash2 size={14} />
</button>
</div>
))}
{data?.items.length === 0 && <p className="text-sm text-zinc-500">Плейлистов нет.</p>}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { albumsApi } from "../lib/api";
import { formatDate } from "../lib/format";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function AlbumPage() {
const { id = "" } = useParams();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({
queryKey: ["album", id],
queryFn: () => albumsApi.get(id),
enabled: Boolean(id),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Альбом не найден.</p>;
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={data.album.coverUrl} name={data.album.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">{data.album.title}</h1>
{data.album.artist && (
<Link to={`/artist/${data.album.artist.id}`} className="link mt-1 block text-sm">
{data.album.artist.name}
</Link>
)}
<p className="mt-1 text-sm text-zinc-500">
{formatDate(data.album.releaseDate)} · {data.tracks.length} треков
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playQueue(data.tracks, 0)} disabled={!data.tracks.length}>
<Play size={15} /> Играть
</button>
<ShareButton path={`/album/${id}`} />
</div>
</div>
</div>
{data.tracks.length ? (
<div className="card p-2">
{data.tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={data.tracks} index={i} showCover={false} showAlbum={false} showUploader />
))}
</div>
) : (
<p className="text-sm text-zinc-500">В альбоме пока нет треков.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, UserCheck, UserPlus } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { artistsApi } from "../lib/api";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function ArtistPage() {
const { id = "" } = useParams();
const qc = useQueryClient();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({
queryKey: ["artist", id],
queryFn: () => artistsApi.get(id),
enabled: Boolean(id),
});
const { data: tracks = [] } = useQuery({
queryKey: ["artist-tracks", id],
queryFn: () => artistsApi.tracks(id),
enabled: Boolean(id),
});
const follow = useMutation({
mutationFn: () => (data?.following ? artistsApi.unfollow(id) : artistsApi.follow(id)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["artist", 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 { artist } = data;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
<Cover src={artist.imageUrl} name={artist.name} rounded="rounded-full" className="h-36 w-36 shrink-0" />
<div className="min-w-0 flex-1">
<span className="chip">Артист</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{artist.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{artist.followersCount} подписчиков
{artist.trackCount !== undefined && ` · ${artist.trackCount} треков`}
{artist.country && ` · ${artist.country}`}
</p>
{artist.description && (
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-zinc-400">{artist.description}</p>
)}
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
<Play size={15} /> Слушать
</button>
<button className="btn-ghost" onClick={() => follow.mutate()}>
{data.following ? <UserCheck size={15} /> : <UserPlus size={15} />}
{data.following ? "Вы подписаны" : "Подписаться"}
</button>
<ShareButton path={`/artist/${id}`} />
</div>
</div>
</div>
{data.albums.length > 0 && (
<section className="mb-8">
<h2 className="section-title mb-3">Альбомы</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.albums.map((al) => (
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
<div className="truncate text-xs text-zinc-500">{al.releaseDate?.slice(0, 4)}</div>
</Link>
))}
</div>
</section>
)}
<section>
<h2 className="section-title mb-3">Треки</h2>
{tracks.length ? (
<div className="card p-2">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={tracks} index={i} showCover={false} showUploader />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Треков пока нет.</p>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useQuery } from "@tanstack/react-query";
import { Play, RefreshCw } from "lucide-react";
import { tracksApi } from "../lib/api";
import { useAuth } from "../store/auth";
import { usePlayer } from "../store/player";
import { TrackRow } from "../components/TrackRow";
export function Home() {
const { user } = useAuth();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["random-tracks"],
queryFn: () => tracksApi.random(25),
});
const items = data ?? [];
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">
Привет{user?.displayName ? `, ${user.displayName}` : ""} 👋
</h1>
<p className="mt-1 text-sm text-zinc-500">
Случайные рекомендации из общей библиотеки. Персональные рекомендации появятся позже.
</p>
</div>
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw size={15} className={isFetching ? "animate-spin" : ""} />
Обновить
</button>
<button
className="btn-primary"
onClick={() => items.length && playQueue(items, 0)}
disabled={!items.length}
>
<Play size={15} /> Играть всё
</button>
</div>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка рекомендаций...</p>
) : items.length === 0 ? (
<div className="card p-8 text-center text-sm text-zinc-500">
В библиотеке пока нет одобренных треков. Загрузите музыку на вкладке «Добавить».
</div>
) : (
<div className="card p-2">
{items.map((track, i) => (
<TrackRow key={track.id} track={track} list={items} index={i} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Play, Plus } from "lucide-react";
import { libraryApi } from "../lib/api";
import { cx } from "../lib/format";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { CreatePlaylistModal } from "../components/CreatePlaylistModal";
import { PlaylistCard } from "../components/PlaylistCard";
import { TrackRow } from "../components/TrackRow";
type Tab = "playlists" | "artists" | "tracks";
export function Library() {
const [tab, setTab] = useState<Tab>("playlists");
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({ queryKey: ["library"], queryFn: libraryApi.get });
const tabs: { id: Tab; label: string }[] = [
{ id: "playlists", label: "Плейлисты" },
{ id: "artists", label: "Артисты" },
{ id: "tracks", label: "Треки" },
];
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Библиотека</h1>
<p className="mb-5 text-sm text-zinc-500">Ваши плейлисты, подписки и избранные треки.</p>
<div className="mb-5 flex flex-wrap items-center gap-2">
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> Создать плейлист
</button>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"rounded-full px-4 py-1.5 text-sm transition",
tab === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
{t.label}
</button>
))}
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : tab === "playlists" ? (
<div className="space-y-8">
<section>
<h2 className="section-title mb-3">Мои плейлисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button
onClick={() => setCreateOpen(true)}
className="card grid aspect-auto place-items-center border-dashed p-3 text-zinc-400 transition hover:bg-white/[0.07] hover:text-white"
>
<div className="flex flex-col items-center gap-2 py-6">
<Plus size={26} />
<span className="text-sm">Создать плейлист</span>
</div>
</button>
{data?.ownedPlaylists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
{!isLoading && !data?.ownedPlaylists.length && (
<p className="mt-3 text-sm text-zinc-500">Пока нет своих плейлистов.</p>
)}
</section>
<section>
<h2 className="section-title mb-3">Сохранённые плейлисты</h2>
{data?.savedPlaylists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.savedPlaylists.map((s) => (
<div key={s.originalPlaylistId} className="relative">
<PlaylistCard playlist={s.playlist} />
<span className="absolute right-2 top-2 chip">
{s.mode === "copy" ? "копия" : "ссылка"}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Вы ещё не добавляли чужие плейлисты.</p>
)}
</section>
</div>
) : tab === "artists" ? (
data?.followedArtists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.followedArtists.map((a) => (
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-24" />
<div className="mt-3 truncate text-center text-sm font-medium text-white">{a.name}</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Вы пока ни на кого не подписаны.</p>
)
) : data?.likedTracks.length ? (
<div>
<div className="mb-3">
<button className="btn-primary" onClick={() => playQueue(data.likedTracks, 0)}>
<Play size={15} /> Играть всё
</button>
</div>
<div className="card p-2">
{data.likedTracks.map((t, i) => (
<TrackRow
key={t.id}
track={t}
list={data.likedTracks}
index={i}
initialLiked
showUploader
/>
))}
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Нет избранных треков.</p>
)}
<CreatePlaylistModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(id) => navigate(`/playlist/${id}`)}
/>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { useAuth } from "../store/auth";
export function Login() {
const { login } = useAuth();
const [form, setForm] = useState({ login: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(form.login, form.password);
} catch (err: any) {
setError(err?.response?.data?.error ?? "Не удалось войти");
} finally {
setLoading(false);
}
};
return (
<div className="grid min-h-full place-items-center p-4">
<div className="w-full max-w-md animate-fade-in">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent to-accent2 text-lg font-black text-black">
AR
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">ADC Retivo</h1>
<p className="mt-1 text-sm text-zinc-500">
Самохостинговая музыка. Вход доступен только по аккаунту, созданному администратором.
</p>
</div>
<form onSubmit={submit} className="card space-y-4 p-6">
<div>
<label className="label">Почта или имя пользователя</label>
<input
className="input"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
autoComplete="username"
required
/>
</div>
<div>
<label className="label">Пароль</label>
<input
className="input"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="current-password"
required
/>
</div>
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
<button className="btn-primary w-full" disabled={loading}>
{loading ? "Входим..." : "Войти"}
</button>
</form>
<p className="mt-6 text-center text-xs text-zinc-600">
Регистрация новых пользователей открыта только из админ-панели.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, Pencil, Play, Plus, Search, Shuffle, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { playlistsApi, tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import type { Playlist, Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { Modal } from "../components/Modal";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function PlaylistPage() {
const { id = "" } = useParams();
const qc = useQueryClient();
const playQueue = usePlayer((s) => s.playQueue);
const [editOpen, setEditOpen] = useState(false);
const [libOpen, setLibOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["playlist", id],
queryFn: () => playlistsApi.get(id),
enabled: Boolean(id),
});
const playlist = data?.playlist;
const entries = data?.entries ?? [];
const tracks = entries.map((e) => e.track).filter(Boolean) as Track[];
const totalMs = tracks.reduce((sum, t) => sum + (t.durationMs ?? 0), 0);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["playlist", id] });
void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
};
const removeEntry = useMutation({
mutationFn: (entryId: string) => playlistsApi.removeTrack(id, entryId),
onSuccess: invalidate,
});
const removePlaylist = useMutation({
mutationFn: () => playlistsApi.remove(id),
onSuccess: () => {
invalidate();
history.back();
},
});
const addToLibrary = useMutation({
mutationFn: (mode: "reference" | "copy") => playlistsApi.addToLibrary(id, mode),
onSuccess: () => {
setLibOpen(false);
invalidate();
},
});
const removeFromLibrary = useMutation({
mutationFn: () => playlistsApi.removeFromLibrary(id),
onSuccess: invalidate,
});
const reorder = useMutation({
mutationFn: (entryIds: string[]) => playlistsApi.reorder(id, entryIds),
onSuccess: invalidate,
});
const move = (from: number, to: number) => {
if (to < 0 || to >= entries.length) return;
const ids = entries.map((e) => e.entryId);
const [moved] = ids.splice(from, 1);
ids.splice(to, 0, moved);
reorder.mutate(ids);
};
const shufflePlay = () => {
if (!tracks.length) return;
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
};
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка плейлиста...</p>;
if (!playlist)
return <p className="text-sm text-zinc-500">Плейлист не найден или недоступен.</p>;
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={playlist.coverUrl} name={playlist.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span className="chip">Плейлист</span>
<span className="chip">
{playlist.visibility === "public"
? "публичный"
: playlist.visibility === "unlisted"
? "по ссылке"
: "приватный"}
</span>
{playlist.isShareable && <span className="chip">можно делиться</span>}
{playlist.allowCollaboratorEdit && <span className="chip">можно редактировать из библиотеки</span>}
</div>
<h1 className="truncate text-3xl font-bold tracking-tight text-white">{playlist.title}</h1>
{playlist.description && <p className="mt-1 text-sm text-zinc-400">{playlist.description}</p>}
<p className="mt-2 text-sm text-zinc-500">
{playlist.owner && (
<>
Автор:{" "}
<Link to={`/u/${playlist.owner.username}`} className="link">
{playlist.owner.displayName || playlist.owner.username}
</Link>
{" · "}
</>
)}
{tracks.length} треков · {formatDuration(totalMs)}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
<Play size={15} /> Играть
</button>
<button className="btn-ghost" onClick={shufflePlay} disabled={!tracks.length}>
<Shuffle size={15} /> Перемешать
</button>
<ShareButton path={`/playlist/${id}`} />
{playlist.canEdit && (
<>
<button className="btn-ghost" onClick={() => setEditOpen(true)}>
<Pencil size={15} /> Редактировать
</button>
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
<Plus size={15} /> Добавить треки
</button>
</>
)}
{data?.inLibrary ? (
<button className="btn-ghost" onClick={() => removeFromLibrary.mutate()}>
<X size={15} /> Убрать из библиотеки
</button>
) : (
<button className="btn-ghost" onClick={() => setLibOpen(true)}>
<Plus size={15} /> В библиотеку
</button>
)}
{playlist.isOwner && (
<button className="btn-danger" onClick={() => confirm("Удалить плейлист?") && removePlaylist.mutate()}>
<Trash2 size={15} /> Удалить
</button>
)}
</div>
</div>
</div>
{tracks.length === 0 ? (
<div className="card p-8 text-center text-sm text-zinc-500">
В плейлисте пока нет треков.
</div>
) : (
<div className="card p-2">
{entries.map((entry, i) =>
entry.track ? (
<TrackRow
key={entry.entryId}
track={entry.track}
list={tracks}
index={i}
showUploader
right={
playlist.canEdit ? (
<div className="flex items-center">
<button
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
disabled={i === 0}
onClick={() => move(i, i - 1)}
title="Выше"
>
<ChevronUp size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
disabled={i === entries.length - 1}
onClick={() => move(i, i + 1)}
title="Ниже"
>
<ChevronDown size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-red-300"
onClick={() => removeEntry.mutate(entry.entryId)}
title="Убрать"
>
<Trash2 size={14} />
</button>
</div>
) : undefined
}
/>
) : null,
)}
</div>
)}
{playlist && (
<EditPlaylistModal
open={editOpen}
onClose={() => setEditOpen(false)}
playlist={playlist}
onSaved={invalidate}
/>
)}
<AddTracksModal open={addOpen} onClose={() => setAddOpen(false)} playlistId={id} onChanged={invalidate} />
<Modal open={libOpen} onClose={() => setLibOpen(false)} title="Добавить плейлист в библиотеку">
<div className="space-y-3 text-sm">
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("reference")}>
<div className="font-medium text-white">Ссылка на оригинал</div>
<p className="mt-1 text-zinc-500">
Плейлист останется общим. Изменения автора будут видны вам. Редактирование — если автор
разрешил.
</p>
</button>
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("copy")}>
<div className="font-medium text-white">Сохранить копию</div>
<p className="mt-1 text-zinc-500">
Создастся личная редактируемая копия, независимая от оригинала.
</p>
</button>
</div>
</Modal>
</div>
);
}
function EditPlaylistModal({
open,
onClose,
playlist,
onSaved,
}: {
open: boolean;
onClose: () => void;
playlist: Playlist;
onSaved: () => void;
}) {
const [form, setForm] = useState({
title: playlist.title,
description: playlist.description ?? "",
visibility: playlist.visibility,
isShareable: playlist.isShareable,
allowCollaboratorEdit: playlist.allowCollaboratorEdit,
});
const save = useMutation({
mutationFn: () => playlistsApi.update(playlist.id, form),
onSuccess: () => {
onSaved();
onClose();
},
});
return (
<Modal open={open} onClose={onClose} title="Редактировать плейлист">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="label">Описание</label>
<textarea
className="input min-h-20"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<label className="label">Доступ</label>
<select
className="input"
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as Playlist["visibility"] })}
>
<option value="private">Приватный</option>
<option value="unlisted">По ссылке</option>
<option value="public">Публичный</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.isShareable}
onChange={(e) => setForm({ ...form, isShareable: e.target.checked })}
/>
Разрешить делиться ссылкой
</label>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.allowCollaboratorEdit}
onChange={(e) => setForm({ ...form, allowCollaboratorEdit: e.target.checked })}
/>
Разрешить редактирование тем, кто добавил плейлист в библиотеку
</label>
<button className="btn-primary w-full" onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</button>
</div>
</Modal>
);
}
function AddTracksModal({
open,
onClose,
playlistId,
onChanged,
}: {
open: boolean;
onClose: () => void;
playlistId: string;
onChanged: () => void;
}) {
const [q, setQ] = useState("");
const { data } = useQuery({
queryKey: ["track-search", q],
queryFn: () => tracksApi.list({ q, limit: 20 }),
enabled: open && q.length > 1,
});
const add = useMutation({
mutationFn: (trackId: string) => playlistsApi.addTrack(playlistId, trackId),
onSuccess: onChanged,
});
return (
<Modal open={open} onClose={onClose} title="Добавить треки" wide>
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input className="input pl-9" placeholder="Поиск по библиотеке..." value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="max-h-80 space-y-1 overflow-y-auto">
{data?.items.map((t) => (
<button
key={t.id}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left hover:bg-white/5"
onClick={() => add.mutate(t.id)}
>
<Cover src={t.coverUrl} name={t.title} className="h-9 w-9" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-white">{t.title}</div>
<div className="truncate text-xs text-zinc-500">{t.artist?.name}</div>
</div>
<Plus size={15} className="text-zinc-500" />
</button>
))}
{q.length > 1 && data?.items.length === 0 && (
<p className="px-2 text-sm text-zinc-500">Ничего не найдено.</p>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,140 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { useState } from "react";
import { searchApi } from "../lib/api";
import { cx } from "../lib/format";
import { Cover } from "../components/Cover";
import { TrackRow } from "../components/TrackRow";
const filters = [
{ id: "all", label: "Всё" },
{ id: "tracks", label: "Треки" },
{ id: "artists", label: "Артисты" },
{ id: "albums", label: "Альбомы" },
{ id: "playlists", label: "Плейлисты" },
{ id: "users", label: "Люди" },
];
export function SearchPage() {
const [params] = useSearchParams();
const q = params.get("q") ?? "";
const [type, setType] = useState("all");
const { data, isLoading } = useQuery({
queryKey: ["search", q, type],
queryFn: () => searchApi.query(q, type),
enabled: q.length > 0,
});
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">
Поиск{q && `: «${q}»`}
</h1>
<p className="mb-4 text-sm text-zinc-500">Треки, артисты, альбомы, публичные плейлисты и люди.</p>
<div className="mb-5 flex flex-wrap gap-2">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setType(f.id)}
className={cx(
"rounded-full px-3.5 py-1.5 text-sm transition",
type === f.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
{f.label}
</button>
))}
</div>
{!q ? (
<p className="text-sm text-zinc-500">Введите запрос в строке поиска сверху.</p>
) : isLoading ? (
<p className="text-sm text-zinc-500">Поиск...</p>
) : (
<div className="space-y-8">
{data!.tracks.length > 0 && (
<section>
<h2 className="section-title mb-3">Треки</h2>
<div className="card p-2">
{data!.tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={data!.tracks} index={i} showUploader />
))}
</div>
</section>
)}
{data!.artists.length > 0 && (
<section>
<h2 className="section-title mb-3">Артисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.artists.map((a) => (
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 text-center hover:bg-white/[0.07]">
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-20" />
<div className="mt-2 truncate text-sm text-white">{a.name}</div>
</Link>
))}
</div>
</section>
)}
{data!.albums.length > 0 && (
<section>
<h2 className="section-title mb-3">Альбомы</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.albums.map((al) => (
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
<div className="truncate text-xs text-zinc-500">{al.artist?.name}</div>
</Link>
))}
</div>
</section>
)}
{data!.playlists.length > 0 && (
<section>
<h2 className="section-title mb-3">Плейлисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.playlists.map((p) => (
<Link key={p.id} to={`/playlist/${p.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={p.coverUrl} name={p.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{p.title}</div>
<div className="truncate text-xs text-zinc-500">
{p.owner?.username} · {p.trackCount} треков
</div>
</Link>
))}
</div>
</section>
)}
{data!.users.length > 0 && (
<section>
<h2 className="section-title mb-3">Люди</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.users.map((u) => (
<Link key={u.id} to={`/u/${u.username}`} className="card p-3 text-center hover:bg-white/[0.07]">
<Cover
src={u.avatarUrl}
name={u.displayName || u.username}
rounded="rounded-full"
className="mx-auto aspect-square w-20"
/>
<div className="mt-2 truncate text-sm text-white">{u.displayName || u.username}</div>
<div className="truncate text-xs text-zinc-500">@{u.username}</div>
</Link>
))}
</div>
</section>
)}
{data!.tracks.length + data!.artists.length + data!.albums.length + data!.playlists.length + data!.users.length ===
0 && <p className="text-sm text-zinc-500">Ничего не найдено.</p>}
</div>
)}
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,62 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
import { usersApi } from "../lib/api";
import { Cover } from "../components/Cover";
import { PlaylistCard } from "../components/PlaylistCard";
import { ShareButton } from "../components/ShareButton";
export function UserPage() {
const { username = "" } = useParams();
const { data, isLoading } = useQuery({
queryKey: ["user", username],
queryFn: () => usersApi.get(username),
enabled: Boolean(username),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Пользователь не найден.</p>;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
<Cover
src={data.user.avatarUrl}
name={data.user.displayName || data.user.username}
rounded="rounded-full"
className="h-32 w-32 shrink-0"
/>
<div className="min-w-0 flex-1">
<span className="chip">Профиль</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">
{data.user.displayName || data.user.username}
</h1>
<p className="text-sm text-zinc-500">@{data.user.username}</p>
{data.user.bio && <p className="mt-2 max-w-2xl text-sm text-zinc-400">{data.user.bio}</p>}
<p className="mt-1 text-xs text-zinc-600">Загружено треков: {data.trackCount}</p>
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
<ShareButton path={`/u/${data.user.username}`} />
</div>
</div>
</div>
<section>
<h2 className="section-title mb-3">Публичные плейлисты</h2>
{data.playlists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.playlists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Публичных плейлистов нет.</p>
)}
</section>
<p className="mt-6 text-xs text-zinc-600">
<Link to="/" className="link">
← На главную
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { authApi } from "../lib/api";
import type { SelfUser } from "../lib/types";
interface AuthContextValue {
user: SelfUser | null;
loading: boolean;
setUser: (user: SelfUser | null) => void;
login: (login: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<SelfUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const u = await authApi.session();
setUser(u);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const login = useCallback(async (loginValue: string, password: string) => {
const u = await authApi.login(loginValue, password);
setUser(u);
}, []);
const logout = useCallback(async () => {
await authApi.logout();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, loading, setUser, login, logout, refresh }),
[user, loading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,170 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Track } from "../lib/types";
export type RepeatMode = "off" | "all" | "one";
interface PlayerState {
queue: Track[];
index: number;
isPlaying: boolean;
shuffle: boolean;
repeat: RepeatMode;
expanded: boolean;
showLyrics: boolean;
currentTime: number;
duration: number;
volume: number;
playNow: (track: Track, list?: Track[]) => void;
playQueue: (tracks: Track[], startIndex?: number) => void;
setQueue: (tracks: Track[]) => void;
toggle: () => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
seek: (time: number) => void;
addToQueue: (track: Track) => void;
playNext: (track: Track) => void;
removeAt: (index: number) => void;
move: (from: number, to: number) => void;
clearQueue: () => void;
toggleShuffle: () => void;
cycleRepeat: () => void;
setExpanded: (v: boolean) => void;
setShowLyrics: (v: boolean) => void;
setPlaying: (v: boolean) => void;
setCurrentTime: (t: number) => void;
setDuration: (d: number) => void;
setVolume: (v: number) => void;
}
export const usePlayer = create<PlayerState>()(
persist(
(set, get) => ({
queue: [],
index: 0,
isPlaying: false,
shuffle: false,
repeat: "off",
expanded: false,
showLyrics: false,
currentTime: 0,
duration: 0,
volume: 1,
playNow: (track, list) =>
set(() => {
const queue = list && list.length ? list : [track];
const index = Math.max(
0,
queue.findIndex((t) => t.id === track.id),
);
return { queue, index, isPlaying: true, currentTime: 0, duration: 0 };
}),
playQueue: (tracks, startIndex = 0) =>
set(() => ({ queue: tracks, index: startIndex, isPlaying: true, currentTime: 0, duration: 0 })),
setQueue: (tracks) => set({ queue: tracks }),
toggle: () => set((s) => (s.queue.length ? { isPlaying: !s.isPlaying } : {})),
next: () => {
const { queue, index, shuffle, repeat } = get();
if (!queue.length) return;
if (repeat === "one") {
set({ currentTime: 0, isPlaying: true });
return;
}
let nextIndex: number;
if (shuffle) {
nextIndex = Math.floor(Math.random() * queue.length);
if (queue.length > 1) {
while (nextIndex === index) nextIndex = Math.floor(Math.random() * queue.length);
}
} else {
nextIndex = index + 1;
}
if (nextIndex >= queue.length) {
if (repeat === "all") nextIndex = 0;
else {
set({ isPlaying: false, index: queue.length - 1 });
return;
}
}
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
},
prev: () => {
const { index, currentTime } = get();
if (currentTime > 3 && index >= 0) {
set({ currentTime: 0 });
return;
}
const nextIndex = index > 0 ? index - 1 : 0;
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
},
goTo: (index) => set({ index, isPlaying: true, currentTime: 0, duration: 0 }),
seek: (time) => set({ currentTime: time }),
addToQueue: (track) => set((s) => ({ queue: [...s.queue, track] })),
playNext: (track) =>
set((s) => {
const queue = [...s.queue];
queue.splice(s.index + 1, 0, track);
return { queue };
}),
removeAt: (index) =>
set((s) => {
const queue = s.queue.filter((_, i) => i !== index);
let newIndex = s.index;
if (index < s.index) newIndex = s.index - 1;
if (newIndex >= queue.length) newIndex = Math.max(0, queue.length - 1);
return { queue, index: newIndex };
}),
move: (from, to) =>
set((s) => {
const queue = [...s.queue];
const [item] = queue.splice(from, 1);
queue.splice(to, 0, item);
let newIndex = s.index;
if (s.index === from) newIndex = to;
else if (from < s.index && to >= s.index) newIndex = s.index - 1;
else if (from > s.index && to <= s.index) newIndex = s.index + 1;
return { queue, index: newIndex };
}),
clearQueue: () => set({ queue: [], index: 0, isPlaying: false, currentTime: 0, duration: 0 }),
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
cycleRepeat: () =>
set((s) => ({ repeat: s.repeat === "off" ? "all" : s.repeat === "all" ? "one" : "off" })),
setExpanded: (v) => set({ expanded: v }),
setShowLyrics: (v) => set({ showLyrics: v }),
setPlaying: (v) => set({ isPlaying: v }),
setCurrentTime: (t) => set({ currentTime: t }),
setDuration: (d) => set({ duration: d }),
setVolume: (v) => set({ volume: v }),
}),
{
name: "adc-retivo-player",
partialize: (s) => ({
queue: s.queue,
index: s.index,
volume: s.volume,
shuffle: s.shuffle,
repeat: s.repeat,
}),
},
),
);
export const selectCurrentTrack = (s: PlayerState): Track | null => s.queue[s.index] ?? null;

View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "#07080c",
panel: "rgba(255,255,255,0.04)",
accent: "#8b5cf6",
accent2: "#22d3ee",
},
fontFamily: {
sans: ["Inter", "system-ui", "Segoe UI", "sans-serif"],
},
backdropBlur: {
xs: "2px",
},
keyframes: {
"fade-in": {
from: { opacity: "0", transform: "translateY(6px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"pulse-slow": {
"0%,100%": { opacity: "0.6" },
"50%": { opacity: "1" },
},
},
animation: {
"fade-in": "fade-in .35s ease both",
"pulse-slow": "pulse-slow 2.4s ease-in-out infinite",
},
},
},
plugins: [],
};

22
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/addtoplaylistmodal.tsx","./src/components/artistlinks.tsx","./src/components/comments.tsx","./src/components/cover.tsx","./src/components/createplaylistmodal.tsx","./src/components/layout.tsx","./src/components/lyricsview.tsx","./src/components/modal.tsx","./src/components/player.tsx","./src/components/playlistcard.tsx","./src/components/queuepanel.tsx","./src/components/sharebutton.tsx","./src/components/trackrow.tsx","./src/lib/api.ts","./src/lib/format.ts","./src/lib/permissions.ts","./src/lib/types.ts","./src/pages/account.tsx","./src/pages/addtrack.tsx","./src/pages/admin.tsx","./src/pages/albumpage.tsx","./src/pages/artistpage.tsx","./src/pages/home.tsx","./src/pages/library.tsx","./src/pages/login.tsx","./src/pages/playlistpage.tsx","./src/pages/searchpage.tsx","./src/pages/trackpage.tsx","./src/pages/userpage.tsx","./src/store/auth.tsx","./src/store/player.tsx","./vite.config.ts"],"version":"5.9.3"}

22
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const proxyTarget = process.env.VITE_PROXY_TARGET || "http://localhost:3001";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
"/api": {
target: proxyTarget,
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: false,
},
});