Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Stoat Music</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1841
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "stoat-mbot-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { Login } from "./components/Login";
|
||||
import { NowPlaying } from "./components/NowPlaying";
|
||||
import { QueueList } from "./components/QueueList";
|
||||
import { SearchPanel } from "./components/SearchPanel";
|
||||
import type { Me, PlayerState, ServerStateResponse, VoiceChannel } from "./types";
|
||||
|
||||
const SERVER_KEY = "mbot.server";
|
||||
|
||||
export function App() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverId, setServerId] = useState<string | null>(null);
|
||||
const [state, setState] = useState<PlayerState | null>(null);
|
||||
const [position, setPosition] = useState(0);
|
||||
const [voiceChannels, setVoiceChannels] = useState<VoiceChannel[]>([]);
|
||||
const [yourVoiceChannel, setYourVoiceChannel] = useState<VoiceChannel | null>(null);
|
||||
const [canControl, setCanControl] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
/** Consumes the one-time link issued by the `!panel` chat command. */
|
||||
const consumeLinkToken = useCallback(async (): Promise<string | null> => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) return null;
|
||||
try {
|
||||
const result = await api.loginWithLink(token);
|
||||
window.history.replaceState({}, "", "/");
|
||||
return result.serverId;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ссылка недействительна");
|
||||
window.history.replaceState({}, "", "/");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMe = useCallback(
|
||||
async (preferredServer?: string | null) => {
|
||||
try {
|
||||
const profile = await api.me();
|
||||
setMe(profile);
|
||||
const stored = preferredServer ?? localStorage.getItem(SERVER_KEY);
|
||||
const chosen = profile.servers.find((server) => server.id === stored) ?? profile.servers[0];
|
||||
setServerId(chosen?.id ?? null);
|
||||
} catch {
|
||||
setMe(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const fromLink = await consumeLinkToken();
|
||||
await loadMe(fromLink);
|
||||
})();
|
||||
}, [consumeLinkToken, loadMe]);
|
||||
|
||||
const applyServerState = useCallback((payload: ServerStateResponse) => {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
setVoiceChannels(payload.voiceChannels);
|
||||
setYourVoiceChannel(payload.yourVoiceChannel);
|
||||
setCanControl(payload.canControl);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
applyServerState(await api.state(id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось получить состояние");
|
||||
}
|
||||
},
|
||||
[applyServerState],
|
||||
);
|
||||
|
||||
// Live updates: the socket carries both full snapshots and 1 Hz position ticks.
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
localStorage.setItem(SERVER_KEY, serverId);
|
||||
void refresh(serverId);
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${protocol}://${window.location.host}/ws?server=${serverId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const payload = JSON.parse(event.data as string) as
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { type: "position"; position: number };
|
||||
if (payload.type === "state") {
|
||||
setState(payload.state);
|
||||
setPosition(payload.state.position);
|
||||
} else {
|
||||
setPosition(payload.position);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [serverId, refresh]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string, payload: Record<string, unknown> = {}) => {
|
||||
if (!serverId) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.action(serverId, action, payload);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Действие не выполнено");
|
||||
}
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
if (loading) return <div className="login-wrap">Загрузка…</div>;
|
||||
if (!me) return <Login onSuccess={() => void loadMe()} />;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="dot" />
|
||||
Stoat Music
|
||||
</div>
|
||||
|
||||
{me.servers.length > 0 && (
|
||||
<select value={serverId ?? ""} onChange={(event) => setServerId(event.target.value)}>
|
||||
{me.servers.map((server) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="spacer" />
|
||||
<span className="who">{me.user.username}</span>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
setMe(null);
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{!serverId || !state ? (
|
||||
<div className="card empty">
|
||||
Бот не состоит ни в одном общем с вами сервере. Пригласите его и обновите страницу.
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout">
|
||||
<div>
|
||||
<SearchPanel
|
||||
serverId={serverId}
|
||||
canControl={canControl}
|
||||
localLibrary={me.features.localLibrary}
|
||||
onError={setError}
|
||||
/>
|
||||
<QueueList
|
||||
tracks={state.queue}
|
||||
canControl={canControl}
|
||||
onRemove={(track) => {
|
||||
void api.removeTrack(serverId, track.id).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onMove={(track, index) => {
|
||||
void api.moveTrack(serverId, track.id, index).catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
onClear={() => void runAction("clear")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NowPlaying
|
||||
state={state}
|
||||
position={position}
|
||||
canControl={canControl}
|
||||
voiceChannels={voiceChannels}
|
||||
yourVoiceChannel={yourVoiceChannel}
|
||||
onAction={(action, payload) => void runAction(action, payload)}
|
||||
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||
onJoin={(channelId) => {
|
||||
void api
|
||||
.join(serverId, channelId)
|
||||
.then(() => refresh(serverId))
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}}
|
||||
/>
|
||||
|
||||
{state.history.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>История</h2>
|
||||
<ul className="track-list">
|
||||
{state.history.map((track, index) => (
|
||||
<li className="track" key={`${track.id}-${index}`}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">{track.author ?? ""}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Me, ServerStateResponse, Track } from "./types";
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: init.body ? { "content-type": "application/json" } : undefined,
|
||||
...init,
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user?: { id: string; username: string };
|
||||
mfaRequired?: boolean;
|
||||
ticket?: string;
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<Me>("/api/me"),
|
||||
|
||||
login: (payload: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
mfaTicket?: string;
|
||||
totpCode?: string;
|
||||
recoveryCode?: string;
|
||||
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
loginWithLink: (token: string) =>
|
||||
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
|
||||
|
||||
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
|
||||
|
||||
search: (serverId: string, query: string) =>
|
||||
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
|
||||
|
||||
play: (
|
||||
serverId: string,
|
||||
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
|
||||
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
join: (serverId: string, voiceChannelId: string | null) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ voiceChannelId }),
|
||||
}),
|
||||
|
||||
removeTrack: (serverId: string, trackId: string) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
|
||||
|
||||
moveTrack: (serverId: string, trackId: string, index: number) =>
|
||||
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
};
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
||||
const total = Math.floor(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [ticket, setTicket] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = ticket
|
||||
? await api.login({ mfaTicket: ticket, totpCode: totp })
|
||||
: await api.login({ email, password });
|
||||
if (result.mfaRequired && result.ticket) {
|
||||
setTicket(result.ticket);
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Не удалось войти");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="card login" onSubmit={submit}>
|
||||
<h1>Stoat Music</h1>
|
||||
<p className="sub">Войдите учётной записью вашего Stoat-инстанса.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{ticket ? (
|
||||
<>
|
||||
<label htmlFor="totp">Код двухфакторной аутентификации</label>
|
||||
<input
|
||||
id="totp"
|
||||
value={totp}
|
||||
onChange={(event) => setTotp(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor="email">E-mail</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="primary" type="submit" disabled={busy}>
|
||||
{busy ? "Проверяю…" : "Войти"}
|
||||
</button>
|
||||
<p className="hint">
|
||||
Пароль уходит напрямую в API вашего инстанса, бот его не сохраняет. Быстрее — команда{" "}
|
||||
<code>!panel</code> в чате: она выдаёт персональную ссылку без пароля.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { formatDuration } from "../api";
|
||||
import type { LoopMode, PlayerState, VoiceChannel } from "../types";
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
position: number;
|
||||
canControl: boolean;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||
onSeek(seconds: number): void;
|
||||
onJoin(channelId: string | null): void;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
||||
idle: "ожидание",
|
||||
connecting: "подключение",
|
||||
buffering: "буферизация",
|
||||
playing: "играет",
|
||||
paused: "пауза",
|
||||
};
|
||||
|
||||
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||
off: "🔁 выкл",
|
||||
track: "🔂 трек",
|
||||
queue: "🔁 очередь",
|
||||
};
|
||||
|
||||
export function NowPlaying({
|
||||
state,
|
||||
position,
|
||||
canControl,
|
||||
voiceChannels,
|
||||
yourVoiceChannel,
|
||||
onAction,
|
||||
onSeek,
|
||||
onJoin,
|
||||
}: Props) {
|
||||
const [volume, setVolume] = useState(state.volume);
|
||||
const [channelId, setChannelId] = useState<string>(
|
||||
state.voiceChannelId ?? yourVoiceChannel?.id ?? voiceChannels[0]?.id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => setVolume(state.volume), [state.volume]);
|
||||
useEffect(() => {
|
||||
if (state.voiceChannelId) setChannelId(state.voiceChannelId);
|
||||
}, [state.voiceChannelId]);
|
||||
|
||||
const track = state.current;
|
||||
const duration = track?.duration ?? 0;
|
||||
const ratio = duration > 0 ? Math.min(1, position / duration) : 0;
|
||||
const isPlaying = state.status === "playing" || state.status === "buffering";
|
||||
|
||||
function seekFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!track || duration <= 0 || !canControl) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const fraction = (event.clientX - rect.left) / rect.width;
|
||||
onSeek(Math.max(0, Math.min(duration - 1, fraction * duration)));
|
||||
}
|
||||
|
||||
const nextLoop: LoopMode = state.loop === "off" ? "track" : state.loop === "track" ? "queue" : "off";
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Сейчас играет{" "}
|
||||
<span className={`status-pill ${state.status === "playing" ? "playing" : ""}`}>
|
||||
{STATUS_LABEL[state.status]}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="now">
|
||||
{track?.thumbnail ? (
|
||||
<img className="cover" src={track.thumbnail} alt="" />
|
||||
) : (
|
||||
<div className="cover placeholder">🎵</div>
|
||||
)}
|
||||
<div className="now-meta">
|
||||
<div className="now-title">
|
||||
{track ? (
|
||||
/^https?:/.test(track.url) ? (
|
||||
<a href={track.url} target="_blank" rel="noreferrer noopener">
|
||||
{track.title}
|
||||
</a>
|
||||
) : (
|
||||
track.title
|
||||
)
|
||||
) : (
|
||||
"Тишина"
|
||||
)}
|
||||
</div>
|
||||
<div className="now-sub">
|
||||
{track
|
||||
? [track.author, `запросил ${track.requestedBy.username}`].filter(Boolean).join(" · ")
|
||||
: "Очередь пуста — найдите что-нибудь слева"}
|
||||
</div>
|
||||
<div className="progress">
|
||||
<div className="bar" onClick={seekFromClick}>
|
||||
<span style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
<div className="times">
|
||||
<span>{track ? formatDuration(position) : "0:00"}</span>
|
||||
<span>{track?.isLive ? "LIVE" : formatDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button
|
||||
className="icon big primary"
|
||||
onClick={() => onAction("toggle")}
|
||||
disabled={!canControl || !track}
|
||||
title={isPlaying ? "Пауза" : "Играть"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("skip")} disabled={!canControl} title="Следующий">
|
||||
⏭
|
||||
</button>
|
||||
<button className="icon" onClick={() => onAction("stop")} disabled={!canControl} title="Стоп">
|
||||
⏹
|
||||
</button>
|
||||
<button
|
||||
className="icon"
|
||||
onClick={() => onAction("shuffle")}
|
||||
disabled={!canControl || state.queue.length < 2}
|
||||
title="Перемешать"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
<button
|
||||
className={state.loop === "off" ? "" : "active"}
|
||||
onClick={() => onAction("loop", { mode: nextLoop })}
|
||||
disabled={!canControl}
|
||||
title="Режим повтора"
|
||||
>
|
||||
{LOOP_LABEL[state.loop]}
|
||||
</button>
|
||||
|
||||
<div className="volume">
|
||||
<span title="Громкость">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={volume}
|
||||
disabled={!canControl}
|
||||
onChange={(event) => setVolume(Number(event.target.value))}
|
||||
onMouseUp={() => onAction("volume", { volume })}
|
||||
onTouchEnd={() => onAction("volume", { volume })}
|
||||
/>
|
||||
<span className="badge">{volume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="voice-row">
|
||||
<select value={channelId} onChange={(event) => setChannelId(event.target.value)} disabled={!canControl}>
|
||||
{voiceChannels.length === 0 && <option value="">Нет голосовых каналов</option>}
|
||||
{voiceChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
{yourVoiceChannel?.id === channel.id ? " (вы здесь)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onJoin(channelId || null)} disabled={!canControl || !channelId}>
|
||||
{state.voiceChannelId === channelId ? "Переподключить" : "Зайти"}
|
||||
</button>
|
||||
<button onClick={() => onAction("leave")} disabled={!canControl || !state.voiceChannelId}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { formatDuration } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
interface Props {
|
||||
tracks: Track[];
|
||||
canControl: boolean;
|
||||
onRemove(track: Track): void;
|
||||
onMove(track: Track, index: number): void;
|
||||
onClear(): void;
|
||||
}
|
||||
|
||||
export function QueueList({ tracks, canControl, onRemove, onMove, onClear }: Props) {
|
||||
const total = tracks.reduce((acc, track) => acc + track.duration, 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>
|
||||
Очередь · {tracks.length}
|
||||
{total > 0 ? ` · ${formatDuration(total)}` : ""}
|
||||
</h2>
|
||||
|
||||
{tracks.length === 0 ? (
|
||||
<div className="empty">Очередь пуста</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="track-list">
|
||||
{tracks.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration), track.requestedBy.username]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
onClick={() => onMove(track, 0)}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Наверх очереди"
|
||||
>
|
||||
⤒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, Math.max(0, index - 1))}
|
||||
disabled={!canControl || index === 0}
|
||||
title="Выше"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMove(track, index + 1)}
|
||||
disabled={!canControl || index === tracks.length - 1}
|
||||
title="Ниже"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button onClick={() => onRemove(track)} disabled={!canControl} title="Убрать">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="ghost" onClick={onClear} disabled={!canControl}>
|
||||
Очистить очередь
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, formatDuration } from "../api";
|
||||
import type { Track } from "../types";
|
||||
|
||||
const SOURCE_BADGE: Record<Track["source"], string> = {
|
||||
youtube: "YouTube",
|
||||
soundcloud: "SoundCloud",
|
||||
direct: "Ссылка",
|
||||
local: "Медиатека",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
canControl: boolean;
|
||||
localLibrary: boolean;
|
||||
onError(message: string | null): void;
|
||||
}
|
||||
|
||||
export function SearchPanel({ serverId, canControl, localLibrary, onError }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Track[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastAdded, setLastAdded] = useState<string | null>(null);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
setBusy(true);
|
||||
onError(null);
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
await api.play(serverId, { query: value });
|
||||
setLastAdded(value);
|
||||
setResults([]);
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
const { tracks } = await api.search(serverId, value);
|
||||
setResults(tracks);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Поиск не удался");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueue(track: Track, mode: "append" | "next" | "now") {
|
||||
onError(null);
|
||||
try {
|
||||
await api.play(serverId, { trackIds: [track.id], mode });
|
||||
setLastAdded(track.title);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Не удалось добавить трек");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Поиск</h2>
|
||||
<form className="search-form" onSubmit={submit}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Название трека или ссылка…"
|
||||
disabled={!canControl}
|
||||
/>
|
||||
<button className="primary" type="submit" disabled={busy || !canControl}>
|
||||
{busy ? "…" : "Найти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="empty">
|
||||
{lastAdded ? `Добавлено: ${lastAdded}` : "Введите запрос или вставьте ссылку — плейлисты тоже поддерживаются."}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="track-list">
|
||||
{results.map((track, index) => (
|
||||
<li className="track" key={track.id}>
|
||||
<span className="idx">{index + 1}</span>
|
||||
{track.thumbnail ? <img className="thumb" src={track.thumbnail} alt="" /> : <div className="thumb" />}
|
||||
<div className="info">
|
||||
<div className="title">{track.title}</div>
|
||||
<div className="sub">
|
||||
{[track.author, track.isLive ? "LIVE" : formatDuration(track.duration)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge">{SOURCE_BADGE[track.source]}</span>
|
||||
<div className="actions">
|
||||
<button onClick={() => enqueue(track, "now")} disabled={!canControl} title="Играть сейчас">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "next")} disabled={!canControl} title="Следующим">
|
||||
⤴
|
||||
</button>
|
||||
<button onClick={() => enqueue(track, "append")} disabled={!canControl} title="В очередь">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="hint">
|
||||
Префиксы: <code>sc:</code> — искать в SoundCloud, <code>yt:</code> — в YouTube
|
||||
{localLibrary ? (
|
||||
<>
|
||||
, <code>local:</code> — в локальной медиатеке
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("root element is missing");
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,475 @@
|
||||
:root {
|
||||
--bg: #0f1014;
|
||||
--bg-elev: #171922;
|
||||
--bg-elev-2: #1e2130;
|
||||
--line: #2a2e3f;
|
||||
--text: #e8eaf2;
|
||||
--muted: #9aa0b5;
|
||||
--accent: #7b6cf6;
|
||||
--accent-soft: rgba(123, 108, 246, 0.16);
|
||||
--danger: #f2555a;
|
||||
--ok: #3ecf8e;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(1200px 600px at 20% -10%, #1b1e2e 0%, var(--bg) 60%);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-elev-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #262a3c;
|
||||
border-color: #3a3f56;
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: #8b7dff;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button.icon.big {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: #cfc7ff;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 18px 60px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 4px 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.brand .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar select {
|
||||
width: auto;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.who {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.now-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.now-title {
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.now-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.now-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.now-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.volume input[type="range"] {
|
||||
width: 110px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 12px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 99px;
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-pill.playing {
|
||||
color: var(--ok);
|
||||
border-color: rgba(62, 207, 142, 0.4);
|
||||
}
|
||||
|
||||
.track-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: var(--bg-elev-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.track .idx {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-elev-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track .info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .sub {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.track:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track .actions button {
|
||||
padding: 5px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 26px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(242, 85, 90, 0.12);
|
||||
border: 1px solid rgba(242, 85, 90, 0.4);
|
||||
color: #ffb4b6;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.login h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.login p.sub {
|
||||
color: var(--muted);
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.login button {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.voice-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voice-row select {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type SourceKind = "youtube" | "soundcloud" | "direct" | "local";
|
||||
export type LoopMode = "off" | "track" | "queue";
|
||||
export type PlayerStatus = "idle" | "connecting" | "buffering" | "playing" | "paused";
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
duration: number;
|
||||
isLive: boolean;
|
||||
url: string;
|
||||
thumbnail: string | null;
|
||||
source: SourceKind;
|
||||
requestedBy: { id: string; username: string };
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
serverId: string;
|
||||
serverName: string | null;
|
||||
voiceChannelId: string | null;
|
||||
voiceChannelName: string | null;
|
||||
status: PlayerStatus;
|
||||
current: Track | null;
|
||||
position: number;
|
||||
queue: Track[];
|
||||
history: Track[];
|
||||
volume: number;
|
||||
loop: LoopMode;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ServerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: { id: string; username: string };
|
||||
servers: ServerRef[];
|
||||
features: { localLibrary: boolean };
|
||||
}
|
||||
|
||||
export interface ServerStateResponse {
|
||||
state: PlayerState;
|
||||
voiceChannels: VoiceChannel[];
|
||||
yourVoiceChannel: VoiceChannel | null;
|
||||
canControl: boolean;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const target = process.env.MBOT_API ?? "http://127.0.0.1:3005";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5180,
|
||||
proxy: {
|
||||
"/api": { target, changeOrigin: true },
|
||||
"/ws": { target, ws: true },
|
||||
},
|
||||
},
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
});
|
||||
Reference in New Issue
Block a user