Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform: Backend (FastAPI + SQLAlchemy 2.0 async): - Auth with JWT (register, login, /me, settings) - Band management with Nextcloud folder integration - Song management with audio version tracking - Nextcloud scan to auto-import audio files - Band membership with link-based invite system - Song comments - Audio analysis worker (BPM, key, loudness, waveform) - Nextcloud activity watcher for auto-import - WebSocket support for real-time annotation updates - Alembic migrations (0001–0003) - Repository pattern, Ruff + mypy configured Frontend (React 18 + Vite + TypeScript strict): - Login/register page with post-login redirect - Home page with band list and creation form - Band page with member panel, invite link, song list, NC scan - Song page with waveform player, annotations, comment thread - Settings page for per-user Nextcloud credentials - Invite acceptance page (/invite/:token) - ESLint v9 flat config + TypeScript strict mode Infrastructure: - Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx - nginx reverse proxy for static files + /api/ proxy - make check runs all linters before docker compose build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run check
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS production
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
21
web/eslint.config.js
Normal file
21
web/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
14
web/index.html
Normal file
14
web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080A0E" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>RehearsalHub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
web/nginx.conf
Normal file
32
web/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to the FastAPI backend
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket support (for /api/v1/ws/*)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SPA routing — all other paths fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively
|
||||
location ~* \.(js|css|woff2|png|svg|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
}
|
||||
4667
web/package-lock.json
generated
Normal file
4667
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "rehearsalhub-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"check": "npm run typecheck && npm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.56.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"wavesurfer.js": "^7.8.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^2.1.1"
|
||||
}
|
||||
}
|
||||
55
web/src/App.tsx
Normal file
55
web/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { BandPage } from "./pages/BandPage";
|
||||
import { SongPage } from "./pages/SongPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { InvitePage } from "./pages/InvitePage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
});
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem("rh_token");
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/bands/:bandId"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<BandPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/songs/:songId"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SongPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SettingsPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
<Route path="/" element={<PrivateRoute><HomePage /></PrivateRoute>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
81
web/src/api/annotations.ts
Normal file
81
web/src/api/annotations.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface RangeAnalysis {
|
||||
id: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
bpm: number | null;
|
||||
bpm_confidence: number | null;
|
||||
key: string | null;
|
||||
scale: string | null;
|
||||
avg_loudness_lufs: number | null;
|
||||
energy: number | null;
|
||||
chroma_vector: number[] | null;
|
||||
mfcc_mean: number[] | null;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
member_id: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
version_id: string;
|
||||
author_id: string;
|
||||
type: "point" | "range";
|
||||
timestamp_ms: number;
|
||||
range_end_ms: number | null;
|
||||
body: string | null;
|
||||
label: string | null;
|
||||
tags: string[];
|
||||
parent_id: string | null;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
range_analysis: RangeAnalysis | null;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
export const listAnnotations = (versionId: string) =>
|
||||
api.get<Annotation[]>(`/versions/${versionId}/annotations`);
|
||||
|
||||
export const createAnnotation = (
|
||||
versionId: string,
|
||||
data: {
|
||||
type: "point" | "range";
|
||||
timestamp_ms: number;
|
||||
range_end_ms?: number;
|
||||
body?: string;
|
||||
label?: string;
|
||||
tags?: string[];
|
||||
parent_id?: string;
|
||||
}
|
||||
) => api.post<Annotation>(`/versions/${versionId}/annotations`, data);
|
||||
|
||||
export const updateAnnotation = (
|
||||
annotationId: string,
|
||||
data: { body?: string; label?: string; tags?: string[]; resolved?: boolean }
|
||||
) => api.patch<Annotation>(`/annotations/${annotationId}`, data);
|
||||
|
||||
export const deleteAnnotation = (annotationId: string) =>
|
||||
api.delete(`/annotations/${annotationId}`);
|
||||
|
||||
export const addReaction = (annotationId: string, emoji: string) =>
|
||||
api.post<Reaction>(`/annotations/${annotationId}/reactions`, { emoji });
|
||||
|
||||
export const searchRanges = (
|
||||
bandId: string,
|
||||
params: { bpm_min?: number; bpm_max?: number; key?: string; tag?: string; min_duration_ms?: number }
|
||||
) => {
|
||||
const qs = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
)
|
||||
);
|
||||
return api.get<Annotation[]>(`/bands/${bandId}/search/ranges?${qs}`);
|
||||
};
|
||||
33
web/src/api/auth.ts
Normal file
33
web/src/api/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api, setToken } from "./client";
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface MemberRead {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function login(req: LoginRequest): Promise<TokenResponse> {
|
||||
const resp = await api.post<TokenResponse>("/auth/login", req);
|
||||
setToken(resp.access_token);
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function register(req: {
|
||||
email: string;
|
||||
password: string;
|
||||
display_name: string;
|
||||
}): Promise<MemberRead> {
|
||||
return api.post<MemberRead>("/auth/register", req);
|
||||
}
|
||||
29
web/src/api/bands.ts
Normal file
29
web/src/api/bands.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface Band {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
genre_tags: string[];
|
||||
nc_folder_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
memberships?: BandMembership[];
|
||||
}
|
||||
|
||||
export interface BandMembership {
|
||||
member: { id: string; email: string; display_name: string };
|
||||
role: string;
|
||||
instrument: string | null;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export const listBands = () => api.get<Band[]>("/bands");
|
||||
export const getBand = (bandId: string) => api.get<Band>(`/bands/${bandId}`);
|
||||
|
||||
export const createBand = (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
genre_tags?: string[];
|
||||
nc_base_path?: string;
|
||||
}) => api.post<Band>("/bands", data);
|
||||
43
web/src/api/client.ts
Normal file
43
web/src/api/client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const BASE = "/api/v1";
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("rh_token");
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("rh_token", token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem("rh_token");
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const resp = await fetch(`${BASE}${path}`, { ...options, headers });
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(error.detail ?? resp.statusText);
|
||||
}
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: (path: string) => request<void>(path, { method: "DELETE" }),
|
||||
};
|
||||
67
web/src/hooks/useWaveform.ts
Normal file
67
web/src/hooks/useWaveform.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
export interface UseWaveformOptions {
|
||||
url: string | null;
|
||||
peaksUrl: string | null;
|
||||
onReady?: (duration: number) => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
}
|
||||
|
||||
export function useWaveform(
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
options: UseWaveformOptions
|
||||
) {
|
||||
const wsRef = useRef<WaveSurfer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !options.url) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor: "#2A3050",
|
||||
progressColor: "#F0A840",
|
||||
cursorColor: "#FFD080",
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
});
|
||||
|
||||
ws.load(options.url);
|
||||
|
||||
ws.on("ready", () => {
|
||||
setIsReady(true);
|
||||
options.onReady?.(ws.getDuration());
|
||||
});
|
||||
|
||||
ws.on("audioprocess", (time) => {
|
||||
setCurrentTime(time);
|
||||
options.onTimeUpdate?.(time);
|
||||
});
|
||||
|
||||
ws.on("play", () => setIsPlaying(true));
|
||||
ws.on("pause", () => setIsPlaying(false));
|
||||
ws.on("finish", () => setIsPlaying(false));
|
||||
|
||||
wsRef.current = ws;
|
||||
return () => {
|
||||
ws.destroy();
|
||||
wsRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options.url]);
|
||||
|
||||
const play = () => wsRef.current?.play();
|
||||
const pause = () => wsRef.current?.pause();
|
||||
const seekTo = (time: number) => {
|
||||
if (wsRef.current && isReady) {
|
||||
wsRef.current.setTime(time);
|
||||
}
|
||||
};
|
||||
|
||||
return { isPlaying, isReady, currentTime, play, pause, seekTo };
|
||||
}
|
||||
40
web/src/hooks/useWebSocket.ts
Normal file
40
web/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
type WsEvent = { event: string; data: unknown };
|
||||
type EventHandler = (data: unknown) => void;
|
||||
|
||||
export function useVersionWebSocket(
|
||||
versionId: string | null,
|
||||
handlers: Record<string, EventHandler>
|
||||
) {
|
||||
const handlersRef = useRef(handlers);
|
||||
useLayoutEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/versions/${versionId}`);
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg: WsEvent = JSON.parse(evt.data);
|
||||
handlersRef.current[msg.event]?.(msg.data);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ event: "ping" }));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
ws.close();
|
||||
};
|
||||
}, [versionId]);
|
||||
}
|
||||
12
web/src/main.tsx
Normal file
12
web/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("No #root element found");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
265
web/src/pages/BandPage.tsx
Normal file
265
web/src/pages/BandPage.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getBand } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface SongSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
version_count: number;
|
||||
}
|
||||
|
||||
interface BandMember {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
interface BandInvite {
|
||||
id: string;
|
||||
token: string;
|
||||
role: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export function BandPage() {
|
||||
const { bandId } = useParams<{ bandId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
|
||||
const { data: band, isLoading } = useQuery({
|
||||
queryKey: ["band", bandId],
|
||||
queryFn: () => getBand(bandId!),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const { data: songs } = useQuery({
|
||||
queryKey: ["songs", bandId],
|
||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ["members", bandId],
|
||||
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["songs", bandId] });
|
||||
setShowCreate(false);
|
||||
setTitle("");
|
||||
setError(null);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
|
||||
});
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => api.post<SongSummary[]>(`/bands/${bandId}/nc-scan`, {}),
|
||||
onSuccess: (imported) => {
|
||||
qc.invalidateQueries({ queryKey: ["songs", bandId] });
|
||||
setScanMsg(
|
||||
imported.length > 0
|
||||
? `Imported ${imported.length} new song${imported.length !== 1 ? "s" : ""} from Nextcloud.`
|
||||
: "No new audio files found in Nextcloud."
|
||||
);
|
||||
setTimeout(() => setScanMsg(null), 4000);
|
||||
},
|
||||
onError: (err) => setScanMsg(err instanceof Error ? err.message : "Scan failed"),
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
|
||||
onSuccess: (invite) => {
|
||||
const url = `${window.location.origin}/invite/${invite.token}`;
|
||||
setInviteLink(url);
|
||||
navigator.clipboard.writeText(url).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
|
||||
});
|
||||
|
||||
// We determine "am I admin?" from GET /auth/me cross-referenced with the members list.
|
||||
// The simplest heuristic: the creator of the band (first admin in the list) is the current user
|
||||
// if they appear with role=admin. We store the current member id in the JWT subject but don't
|
||||
// expose it yet, so we compare by checking if the members list has exactly one admin and we
|
||||
// can tell by the invite button being available on the backend (403 vs 201).
|
||||
// For the UI we just show the Remove button for non-admin members and let the API enforce auth.
|
||||
|
||||
if (isLoading) return <div style={{ color: "#5A6480", padding: 32 }}>Loading...</div>;
|
||||
if (!band) return <div style={{ color: "#E85878", padding: 32 }}>Band not found</div>;
|
||||
|
||||
const amAdmin = members?.some((m) => m.role === "admin") ?? false;
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 32 }}>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<Link to="/" style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 20 }}>
|
||||
← All Bands
|
||||
</Link>
|
||||
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1>
|
||||
{band.genre_tags.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
|
||||
{band.genre_tags.map((t: string) => (
|
||||
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Members ── */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Members</h2>
|
||||
<button
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
disabled={inviteMutation.isPending}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#F0A840", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
+ Invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div style={{ background: "#0E1118", border: "1px solid #F0A840", borderRadius: 6, padding: "10px 14px", marginBottom: 12 }}>
|
||||
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
|
||||
<code style={{ color: "#F0A840", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
|
||||
<button
|
||||
onClick={() => setInviteLink(null)}
|
||||
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "#5A6480", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
{members?.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 6, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500 }}>{m.display_name}</span>
|
||||
<span style={{ color: "#5A6480", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
|
||||
background: m.role === "admin" ? "#2A1E08" : "#0E1118",
|
||||
color: m.role === "admin" ? "#F0A840" : "#5A6480",
|
||||
border: `1px solid ${m.role === "admin" ? "#F0A840" : "#1C2235"}`,
|
||||
}}>
|
||||
{m.role}
|
||||
</span>
|
||||
{amAdmin && m.role !== "admin" && (
|
||||
<button
|
||||
onClick={() => removeMemberMutation.mutate(m.id)}
|
||||
style={{ background: "none", border: "none", color: "#E85878", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Songs ── */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Songs</h2>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={scanMutation.isPending}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#38C9A8", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
{scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
+ New Song
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scanMsg && (
|
||||
<div style={{ background: "#0A2820", border: "1px solid #38C9A8", borderRadius: 6, color: "#38C9A8", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
|
||||
{scanMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 16 }}>
|
||||
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={{ color: "#5A6480", fontSize: 11, margin: "0 0 12px" }}>
|
||||
A folder <code style={{ color: "#38C9A8" }}>bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/</code> will be created in Nextcloud.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!title}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setError(null); }}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{songs?.map((song) => (
|
||||
<Link
|
||||
key={song.id}
|
||||
to={`/bands/${bandId}/songs/${song.id}`}
|
||||
style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: "14px 18px", textDecoration: "none", color: "#E2E6F0", display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<span>{song.title}</span>
|
||||
<span style={{ color: "#5A6480", fontSize: 12 }}>
|
||||
<span style={{ background: "#0E1118", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
|
||||
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{songs?.length === 0 && (
|
||||
<p style={{ color: "#5A6480", fontSize: 13 }}>
|
||||
No songs yet. Create one or scan Nextcloud to import from <code style={{ color: "#38C9A8" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
web/src/pages/HomePage.tsx
Normal file
142
web/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { listBands, createBand } from "../api/bands";
|
||||
import { clearToken } from "../api/client";
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [ncBasePath, setNcBasePath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: bands, isLoading } = useQuery({
|
||||
queryKey: ["bands"],
|
||||
queryFn: listBands,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createBand({ name, slug, ...(ncBasePath ? { nc_base_path: ncBasePath } : {}) }),
|
||||
onSuccess: (band) => {
|
||||
qc.invalidateQueries({ queryKey: ["bands"] });
|
||||
setName(""); setSlug(""); setNcBasePath("");
|
||||
navigate(`/bands/${band.id}`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
|
||||
});
|
||||
|
||||
function handleSignOut() {
|
||||
clearToken();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
|
||||
<div style={{ maxWidth: 720, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: 0 }}>◈ RehearsalHub</h1>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<h2 style={{ color: "#E2E6F0", margin: 0, fontSize: 16 }}>Your Bands</h2>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
+ New Band
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 20, marginBottom: 20 }}>
|
||||
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>BAND NAME</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
|
||||
}}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }}
|
||||
/>
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>SLUG</label>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 16, fontSize: 14, fontFamily: "monospace", boxSizing: "border-box" }}
|
||||
/>
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>
|
||||
NEXTCLOUD BASE FOLDER <span style={{ color: "#38496A" }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={ncBasePath}
|
||||
onChange={(e) => setNcBasePath(e.target.value)}
|
||||
placeholder={`bands/${slug || "my-band"}/`}
|
||||
style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 4, fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
|
||||
/>
|
||||
<p style={{ color: "#38496A", fontSize: 11, margin: "0 0 16px" }}>
|
||||
Path relative to your Nextcloud root. Leave blank to use <code style={{ color: "#5A6480" }}>bands/{slug || "slug"}/</code>
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!name || !slug}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(false); setError(null); setNcBasePath(""); }}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "8px 18px", fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p style={{ color: "#5A6480" }}>Loading...</p>}
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{bands?.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => navigate(`/bands/${band.id}`)}
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: 16, textAlign: "left", cursor: "pointer", color: "#E2E6F0" }}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{band.name}</div>
|
||||
<div style={{ fontSize: 11, color: "#5A6480", fontFamily: "monospace" }}>{band.slug}</div>
|
||||
{band.genre_tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 4 }}>
|
||||
{band.genre_tags.map((t) => (
|
||||
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{bands?.length === 0 && (
|
||||
<p style={{ color: "#5A6480", fontSize: 13 }}>No bands yet. Create one to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
web/src/pages/InvitePage.tsx
Normal file
105
web/src/pages/InvitePage.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface InviteInfo {
|
||||
id: string;
|
||||
band_id: string;
|
||||
token: string;
|
||||
role: string;
|
||||
expires_at: string;
|
||||
used_at: string | null;
|
||||
}
|
||||
|
||||
export function InvitePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [invite, setInvite] = useState<InviteInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const isLoggedIn = !!localStorage.getItem("rh_token");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.get<InviteInfo>(`/invites/${token}`)
|
||||
.then(setInvite)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Invalid invite"));
|
||||
}, [token]);
|
||||
|
||||
async function accept() {
|
||||
if (!token) return;
|
||||
setAccepting(true);
|
||||
try {
|
||||
await api.post(`/invites/${token}/accept`, {});
|
||||
setDone(true);
|
||||
setTimeout(() => navigate("/"), 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to accept invite");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
navigate(`/login?next=/invite/${token}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", color: "#E2E6F0" }}>
|
||||
<div style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 12, padding: 40, maxWidth: 420, width: "100%", textAlign: "center" }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", marginBottom: 8, fontSize: 22 }}>◈ RehearsalHub</h1>
|
||||
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 28 }}>Band invite</p>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: "#1A0810", border: "1px solid #E85878", borderRadius: 6, padding: "12px 16px", color: "#E85878", fontSize: 13, marginBottom: 20 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && (
|
||||
<div style={{ color: "#38C9A8", fontSize: 14 }}>
|
||||
Joined! Redirecting…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!done && invite && (
|
||||
<>
|
||||
<p style={{ color: "#E2E6F0", fontSize: 15, marginBottom: 6 }}>
|
||||
You've been invited to join a band as <strong style={{ color: "#F0A840" }}>{invite.role}</strong>.
|
||||
</p>
|
||||
<p style={{ color: "#5A6480", fontSize: 12, marginBottom: 28 }}>
|
||||
Expires {new Date(invite.expires_at).toLocaleDateString()}
|
||||
{invite.used_at && " · Already used"}
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={accept}
|
||||
disabled={accepting || !!invite.used_at}
|
||||
style={{ width: "100%", background: "#F0A840", border: "none", borderRadius: 8, color: "#080A0E", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
|
||||
>
|
||||
{accepting ? "Joining…" : "Accept Invite"}
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<p style={{ color: "#5A6480", fontSize: 13, marginBottom: 16 }}>Log in or register to accept this invite.</p>
|
||||
<button
|
||||
onClick={goLogin}
|
||||
style={{ width: "100%", background: "#F0A840", border: "none", borderRadius: 8, color: "#080A0E", cursor: "pointer", padding: "12px 0", fontWeight: 700, fontSize: 15 }}
|
||||
>
|
||||
Log in / Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!done && !invite && !error && (
|
||||
<p style={{ color: "#5A6480" }}>Loading invite…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
web/src/pages/LoginPage.tsx
Normal file
95
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { login, register } from "../api/auth";
|
||||
|
||||
export function LoginPage() {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await login({ email, password });
|
||||
} else {
|
||||
await register({ email, password, display_name: displayName });
|
||||
await login({ email, password });
|
||||
}
|
||||
navigate(searchParams.get("next") ?? "/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : mode === "login" ? "Login failed" : "Registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%", padding: "10px 12px", background: "#131720",
|
||||
border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0",
|
||||
marginBottom: 16, fontSize: 14, boxSizing: "border-box",
|
||||
};
|
||||
const labelStyle: React.CSSProperties = { display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#080A0E" }}>
|
||||
<form onSubmit={handleSubmit} style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 12, padding: 32, width: 340 }}>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", marginBottom: 8 }}>◈ RehearsalHub</h1>
|
||||
<p style={{ color: "#5A6480", fontSize: 12, marginBottom: 24 }}>
|
||||
{mode === "login" ? "Sign in to your account" : "Create a new account"}
|
||||
</p>
|
||||
|
||||
{error && <p style={{ color: "#E85878", marginBottom: 16, fontSize: 13 }}>{error}</p>}
|
||||
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<label style={labelStyle}>DISPLAY NAME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label style={labelStyle}>EMAIL</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<label style={labelStyle}>PASSWORD</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ ...inputStyle, marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<button type="submit" style={{ width: "100%", padding: "12px", background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", fontWeight: 600, cursor: "pointer", fontSize: 14 }}>
|
||||
{mode === "login" ? "Sign In" : "Create Account"}
|
||||
</button>
|
||||
|
||||
<p style={{ textAlign: "center", marginTop: 16, fontSize: 12, color: "#5A6480" }}>
|
||||
{mode === "login" ? "Don't have an account? " : "Already have an account? "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode(mode === "login" ? "register" : "login"); setError(null); }}
|
||||
style={{ background: "none", border: "none", color: "#F0A840", cursor: "pointer", fontSize: 12, padding: 0 }}
|
||||
>
|
||||
{mode === "login" ? "Register" : "Sign In"}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
web/src/pages/SettingsPage.tsx
Normal file
147
web/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface MemberRead {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
nc_username: string | null;
|
||||
nc_url: string | null;
|
||||
nc_configured: boolean;
|
||||
}
|
||||
|
||||
const getMe = () => api.get<MemberRead>("/auth/me");
|
||||
const updateSettings = (data: {
|
||||
display_name?: string;
|
||||
nc_url?: string;
|
||||
nc_username?: string;
|
||||
nc_password?: string;
|
||||
}) => api.patch<MemberRead>("/auth/me/settings", data);
|
||||
|
||||
// Rendered only after `me` is loaded — initializes form state directly from props.
|
||||
function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [displayName, setDisplayName] = useState(me.display_name ?? "");
|
||||
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
|
||||
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
|
||||
const [ncPassword, setNcPassword] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSettings({
|
||||
display_name: displayName || undefined,
|
||||
nc_url: ncUrl || undefined,
|
||||
nc_username: ncUsername || undefined,
|
||||
nc_password: ncPassword || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["me"] });
|
||||
setSaved(true);
|
||||
setNcPassword("");
|
||||
setError(null);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Save failed"),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 13, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 16 }}>PROFILE</h2>
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>DISPLAY NAME</label>
|
||||
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} style={inputStyle} />
|
||||
<p style={{ color: "#38496A", fontSize: 11, margin: "4px 0 0" }}>{me.email}</p>
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 13, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}>NEXTCLOUD CONNECTION</h2>
|
||||
<p style={{ color: "#38496A", fontSize: 12, marginBottom: 16 }}>
|
||||
Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials.
|
||||
</p>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
|
||||
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "#38C9A8" : "#5A6480" }} />
|
||||
<span style={{ fontSize: 12, color: me.nc_configured ? "#38C9A8" : "#5A6480" }}>
|
||||
{me.nc_configured ? "Connected" : "Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }}>NEXTCLOUD URL</label>
|
||||
<input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} />
|
||||
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6, marginTop: 12 }}>USERNAME</label>
|
||||
<input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} style={inputStyle} />
|
||||
|
||||
<label style={{ display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6, marginTop: 12 }}>PASSWORD / APP PASSWORD</label>
|
||||
<input
|
||||
type="password"
|
||||
value={ncPassword}
|
||||
onChange={(e) => setNcPassword(e.target.value)}
|
||||
placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<p style={{ color: "#38496A", fontSize: 11, margin: "4px 0 0" }}>
|
||||
Use an app password from Nextcloud Settings → Security for better security.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{error && <p style={{ color: "#E85878", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||
{saved && <p style={{ color: "#38C9A8", fontSize: 13, marginBottom: 12 }}>Settings saved.</p>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "10px 24px", fontWeight: 600, fontSize: 14 }}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 6, color: "#5A6480", cursor: "pointer", padding: "10px 18px", fontSize: 14 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
|
||||
<div style={{ maxWidth: 540, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32 }}>
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
style={{ background: "none", border: "none", color: "#5A6480", cursor: "pointer", fontSize: 13, padding: 0 }}
|
||||
>
|
||||
← All Bands
|
||||
</button>
|
||||
<h1 style={{ color: "#F0A840", fontFamily: "monospace", margin: 0, fontSize: 20 }}>Settings</h1>
|
||||
</div>
|
||||
|
||||
{isLoading && <p style={{ color: "#5A6480" }}>Loading...</p>}
|
||||
{me && <SettingsForm me={me} onBack={() => navigate("/")} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "#131720",
|
||||
border: "1px solid #1C2235",
|
||||
borderRadius: 6,
|
||||
color: "#E2E6F0",
|
||||
fontSize: 14,
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
229
web/src/pages/SongPage.tsx
Normal file
229
web/src/pages/SongPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { listAnnotations, addReaction } from "../api/annotations";
|
||||
import { useVersionWebSocket } from "../hooks/useWebSocket";
|
||||
import { useWaveform } from "../hooks/useWaveform";
|
||||
import type { Annotation } from "../api/annotations";
|
||||
|
||||
interface SongComment {
|
||||
id: string;
|
||||
song_id: string;
|
||||
body: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function SongPage() {
|
||||
const { bandId, songId } = useParams<{ bandId: string; songId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
|
||||
const { data: versions } = useQuery({
|
||||
queryKey: ["versions", songId],
|
||||
queryFn: () => api.get<{ id: string; version_number: number; label: string | null; analysis_status: string }[]>(`/songs/${songId}/versions`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
|
||||
|
||||
const { data: annotations } = useQuery({
|
||||
queryKey: ["annotations", activeVersion],
|
||||
queryFn: () => listAnnotations(activeVersion!),
|
||||
enabled: !!activeVersion,
|
||||
});
|
||||
|
||||
const { isPlaying, currentTime, play, pause, seekTo } = useWaveform(waveformRef, {
|
||||
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
|
||||
peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null,
|
||||
});
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: ["comments", songId],
|
||||
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
|
||||
enabled: !!songId,
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: (body: string) => api.post(`/songs/${songId}/comments`, { body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["comments", songId] });
|
||||
setCommentBody("");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCommentMutation = useMutation({
|
||||
mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
|
||||
});
|
||||
|
||||
const invalidateAnnotations = useCallback(
|
||||
() => qc.invalidateQueries({ queryKey: ["annotations", activeVersion] }),
|
||||
[qc, activeVersion]
|
||||
);
|
||||
|
||||
useVersionWebSocket(activeVersion, {
|
||||
"annotation.created": invalidateAnnotations,
|
||||
"annotation.updated": invalidateAnnotations,
|
||||
"annotation.deleted": invalidateAnnotations,
|
||||
"reaction.added": invalidateAnnotations,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ background: "#080A0E", minHeight: "100vh", color: "#E2E6F0", padding: 24 }}>
|
||||
<Link to={`/bands/${bandId}`} style={{ color: "#5A6480", fontSize: 12, textDecoration: "none", display: "inline-block", marginBottom: 16 }}>
|
||||
← Back to Band
|
||||
</Link>
|
||||
|
||||
{/* Version selector */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
|
||||
{versions?.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
style={{
|
||||
background: v.id === activeVersion ? "#2A1E08" : "#131720",
|
||||
border: `1px solid ${v.id === activeVersion ? "#F0A840" : "#1C2235"}`,
|
||||
borderRadius: 6, padding: "6px 14px", color: v.id === activeVersion ? "#F0A840" : "#5A6480",
|
||||
cursor: "pointer", fontSize: 12, fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
v{v.version_number} {v.label ?? ""} · {v.analysis_status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Waveform */}
|
||||
<div
|
||||
style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "16px 16px 8px", marginBottom: 16 }}
|
||||
onClick={(_e) => {
|
||||
// TODO: seek on click (needs duration from wavesurfer)
|
||||
}}
|
||||
>
|
||||
<div ref={waveformRef} />
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={isPlaying ? pause : play}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, padding: "6px 18px", cursor: "pointer", fontWeight: 600, color: "#080A0E" }}
|
||||
>
|
||||
{isPlaying ? "⏸ Pause" : "▶ Play"}
|
||||
</button>
|
||||
<span style={{ color: "#5A6480", fontSize: 12, alignSelf: "center" }}>
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations */}
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 32 }}>
|
||||
{annotations?.map((a) => (
|
||||
<AnnotationCard key={a.id} annotation={a} onSeek={seekTo} versionId={activeVersion!} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 14, color: "#5A6480", fontFamily: "monospace", letterSpacing: 1, marginBottom: 14 }}>COMMENTS</h2>
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginBottom: 16 }}>
|
||||
{comments?.map((c) => (
|
||||
<div key={c.id} style={{ background: "#0E1118", border: "1px solid #1C2235", borderRadius: 8, padding: "12px 16px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: "#E2E6F0" }}>{c.author_name}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ color: "#38496A", fontSize: 11 }}>{new Date(c.created_at).toLocaleString()}</span>
|
||||
<button
|
||||
onClick={() => deleteCommentMutation.mutate(c.id)}
|
||||
style={{ background: "none", border: "none", color: "#38496A", cursor: "pointer", fontSize: 11, padding: 0 }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#C8CDD8", lineHeight: 1.5 }}>{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
{comments?.length === 0 && (
|
||||
<p style={{ color: "#38496A", fontSize: 13 }}>No comments yet. Be the first.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
rows={2}
|
||||
style={{ flex: 1, padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", fontSize: 13, resize: "vertical", fontFamily: "inherit" }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => commentBody.trim() && addCommentMutation.mutate(commentBody.trim())}
|
||||
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
||||
style={{ background: "#F0A840", border: "none", borderRadius: 6, color: "#080A0E", cursor: "pointer", padding: "0 18px", fontWeight: 600, fontSize: 13, alignSelf: "stretch" }}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnotationCard({ annotation: a, onSeek, versionId }: { annotation: Annotation; onSeek: (t: number) => void; versionId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const reactionMutation = useMutation({
|
||||
mutationFn: (emoji: string) => addReaction(a.id, emoji),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["annotations", versionId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ background: "#131720", border: "1px solid #1C2235", borderRadius: 8, padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => onSeek(a.timestamp_ms / 1000)}
|
||||
style={{ background: "#2A1E08", border: "1px solid #F0A840", borderRadius: 4, color: "#F0A840", cursor: "pointer", fontSize: 10, padding: "2px 8px", fontFamily: "monospace" }}
|
||||
>
|
||||
{formatTime(a.timestamp_ms / 1000)}
|
||||
{a.range_end_ms != null && ` → ${formatTime(a.range_end_ms / 1000)}`}
|
||||
</button>
|
||||
<span style={{ color: "#5A6480", fontSize: 11 }}>{a.type}</span>
|
||||
{a.label && <span style={{ color: "#38C9A8", fontSize: 11 }}>{a.label}</span>}
|
||||
{a.tags.map((t) => (
|
||||
<span key={t} style={{ background: "#0A2820", color: "#38C9A8", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
{a.body && <p style={{ color: "#E2E6F0", margin: 0, fontSize: 13 }}>{a.body}</p>}
|
||||
{a.range_analysis && (
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 12, fontSize: 11, color: "#5A6480" }}>
|
||||
{a.range_analysis.bpm && <span>♩ {a.range_analysis.bpm.toFixed(1)} BPM</span>}
|
||||
{a.range_analysis.key && <span>🎵 {a.range_analysis.key}</span>}
|
||||
{a.range_analysis.avg_loudness_lufs && <span>{a.range_analysis.avg_loudness_lufs.toFixed(1)} LUFS</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", gap: 4 }}>
|
||||
{["🔥", "💡", "✅", "❓"].map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => reactionMutation.mutate(emoji)}
|
||||
style={{ background: "none", border: "1px solid #1C2235", borderRadius: 4, cursor: "pointer", padding: "2px 6px", fontSize: 14 }}
|
||||
>
|
||||
{emoji}{" "}
|
||||
<span style={{ fontSize: 10, color: "#5A6480" }}>
|
||||
{a.reactions.filter((r) => r.emoji === emoji).length || ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
web/vite.config.ts
Normal file
18
web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
"/ws": { target: "ws://localhost:8000", ws: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user