Compare commits
13 Commits
feature/ma
...
fix/login-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf9f52f6f | ||
|
|
a31f7db619 | ||
|
|
ba90f581ae | ||
|
|
a8cbd333d2 | ||
|
|
261942be03 | ||
|
|
4358461107 | ||
|
|
3a7d8de69e | ||
|
|
44503cca30 | ||
|
|
c562c3da4a | ||
|
|
659598913b | ||
|
|
013a2fc2d6 | ||
|
|
b09094658c | ||
|
|
aa889579a0 |
@@ -2,11 +2,15 @@ FROM python:3.12-slim AS base
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
|
|
||||||
FROM base AS development
|
FROM python:3.12-slim AS development
|
||||||
|
WORKDIR /app
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN uv sync
|
COPY src/ src/
|
||||||
COPY . .
|
# Install directly into system Python — no venv, so uvicorn's multiprocessing.spawn
|
||||||
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
# subprocess inherits the same interpreter and can always find rehearsalhub
|
||||||
|
RUN pip install --no-cache-dir -e "."
|
||||||
|
# ./api/src is mounted as a volume at runtime; the editable .pth file points here
|
||||||
|
CMD ["python3", "-m", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
FROM base AS lint
|
FROM base AS lint
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
|
|||||||
25
api/alembic/versions/0005_comment_tag.py
Normal file
25
api/alembic/versions/0005_comment_tag.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add tag column to song_comments
|
||||||
|
|
||||||
|
Revision ID: 0005_comment_tag
|
||||||
|
Revises: 0004_rehearsal_sessions
|
||||||
|
Create Date: 2026-04-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0005_comment_tag"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"song_comments",
|
||||||
|
sa.Column("tag", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("song_comments", "tag")
|
||||||
@@ -207,6 +207,7 @@ class SongComment(Base):
|
|||||||
)
|
)
|
||||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||||
|
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,6 +89,24 @@ async def search_songs(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/songs/{song_id}", response_model=SongRead)
|
||||||
|
async def get_song(
|
||||||
|
song_id: uuid.UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
current_member: Member = Depends(get_current_member),
|
||||||
|
):
|
||||||
|
song_repo = SongRepository(session)
|
||||||
|
song = await song_repo.get_with_versions(song_id)
|
||||||
|
if song is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
|
||||||
|
band_svc = BandService(session)
|
||||||
|
try:
|
||||||
|
await band_svc.assert_membership(song.band_id, current_member.id)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||||
|
return SongRead.model_validate(song).model_copy(update={"version_count": len(song.versions)})
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/songs/{song_id}", response_model=SongRead)
|
@router.patch("/songs/{song_id}", response_model=SongRead)
|
||||||
async def update_song(
|
async def update_song(
|
||||||
song_id: uuid.UUID,
|
song_id: uuid.UUID,
|
||||||
@@ -264,7 +282,7 @@ async def create_comment(
|
|||||||
):
|
):
|
||||||
await _assert_song_membership(song_id, current_member.id, session)
|
await _assert_song_membership(song_id, current_member.id, session)
|
||||||
repo = CommentRepository(session)
|
repo = CommentRepository(session)
|
||||||
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp)
|
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body, timestamp=data.timestamp, tag=data.tag)
|
||||||
comment = await repo.get_with_author(comment.id)
|
comment = await repo.get_with_author(comment.id)
|
||||||
return SongCommentRead.from_model(comment)
|
return SongCommentRead.from_model(comment)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
class SongCommentCreate(BaseModel):
|
class SongCommentCreate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
timestamp: float | None = None
|
timestamp: float | None = None
|
||||||
|
tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SongCommentRead(BaseModel):
|
class SongCommentRead(BaseModel):
|
||||||
@@ -21,6 +22,7 @@ class SongCommentRead(BaseModel):
|
|||||||
author_name: str
|
author_name: str
|
||||||
author_avatar_url: str | None
|
author_avatar_url: str | None
|
||||||
timestamp: float | None
|
timestamp: float | None
|
||||||
|
tag: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -33,5 +35,6 @@ class SongCommentRead(BaseModel):
|
|||||||
author_name=getattr(getattr(c, "author"), "display_name"),
|
author_name=getattr(getattr(c, "author"), "display_name"),
|
||||||
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
||||||
timestamp=getattr(c, "timestamp"),
|
timestamp=getattr(c, "timestamp"),
|
||||||
|
tag=getattr(c, "tag", None),
|
||||||
created_at=getattr(c, "created_at"),
|
created_at=getattr(c, "created_at"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,67 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-rh_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
|
||||||
|
volumes:
|
||||||
|
- pg_data_dev:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- rh_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub} || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
networks:
|
||||||
|
- rh_net
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
target: development
|
target: development
|
||||||
volumes:
|
volumes:
|
||||||
- ./api/src:/app/src
|
- ./api/src:/app/src
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
||||||
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
|
||||||
|
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
|
||||||
|
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
|
||||||
|
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
|
||||||
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
networks:
|
||||||
|
- rh_net
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
audio-worker:
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
target: development
|
||||||
volumes:
|
volumes:
|
||||||
- ./worker/src:/app/src
|
- ./web/src:/app/src
|
||||||
|
environment:
|
||||||
|
API_URL: http://api:8000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
networks:
|
||||||
|
- rh_net
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
nc-watcher:
|
networks:
|
||||||
volumes:
|
rh_net:
|
||||||
- ./watcher/src:/app/src
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data_dev:
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
FROM node:20-alpine AS development
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
COPY . .
|
||||||
|
# ./web/src is mounted as a volume at runtime for HMR; everything else comes from the image
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function useWaveform(
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
const markersRef = useRef<CommentMarker[]>([]);
|
||||||
|
|
||||||
@@ -31,12 +32,12 @@ export function useWaveform(
|
|||||||
|
|
||||||
const ws = WaveSurfer.create({
|
const ws = WaveSurfer.create({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
waveColor: "#2A3050",
|
waveColor: "rgba(255,255,255,0.09)",
|
||||||
progressColor: "#F0A840",
|
progressColor: "#c8861a",
|
||||||
cursorColor: "#FFD080",
|
cursorColor: "#e8a22a",
|
||||||
barWidth: 2,
|
barWidth: 2,
|
||||||
barRadius: 2,
|
barRadius: 2,
|
||||||
height: 80,
|
height: 104,
|
||||||
normalize: true,
|
normalize: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export function useWaveform(
|
|||||||
|
|
||||||
ws.on("ready", () => {
|
ws.on("ready", () => {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
setDuration(ws.getDuration());
|
||||||
options.onReady?.(ws.getDuration());
|
options.onReady?.(ws.getDuration());
|
||||||
// Reset playing state when switching versions
|
// Reset playing state when switching versions
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -141,7 +143,7 @@ export function useWaveform(
|
|||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
return { isPlaying, isReady, currentTime, play, pause, seekTo, addMarker, clearMarkers };
|
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
@@ -43,14 +43,13 @@ const renderBandPage = () =>
|
|||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
|
describe("BandPage — Library view (TC-01 to TC-09)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("TC-01: does not render a member list", async () => {
|
it("TC-01: does not render a member list", async () => {
|
||||||
renderBandPage();
|
renderBandPage();
|
||||||
// Allow queries to settle
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
expect(screen.queryByText(/members/i)).toBeNull();
|
expect(screen.queryByText(/members/i)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -70,7 +69,6 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
|
|||||||
|
|
||||||
it("TC-04: renders sessions grouped by date", async () => {
|
it("TC-04: renders sessions grouped by date", async () => {
|
||||||
renderBandPage();
|
renderBandPage();
|
||||||
// Sessions appear after the query resolves
|
|
||||||
const sessionEl = await screen.findByText("Late Night Jam");
|
const sessionEl = await screen.findByText("Late Night Jam");
|
||||||
expect(sessionEl).toBeTruthy();
|
expect(sessionEl).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -81,17 +79,30 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
|
|||||||
expect(btn).toBeTruthy();
|
expect(btn).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("TC-06: renders the + New Song button", async () => {
|
it("TC-06: renders the + Upload button", async () => {
|
||||||
renderBandPage();
|
renderBandPage();
|
||||||
const btn = await screen.findByText(/\+ new song/i);
|
const btn = await screen.findByText(/\+ upload/i);
|
||||||
expect(btn).toBeTruthy();
|
expect(btn).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("TC-07: renders both By Date and Search tabs", async () => {
|
it("TC-07: does not render By Date / Search tabs", async () => {
|
||||||
renderBandPage();
|
renderBandPage();
|
||||||
const byDate = await screen.findByText(/by date/i);
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
const search = await screen.findByText(/^search$/i);
|
expect(screen.queryByText(/by date/i)).toBeNull();
|
||||||
expect(byDate).toBeTruthy();
|
expect(screen.queryByText(/^search$/i)).toBeNull();
|
||||||
expect(search).toBeTruthy();
|
});
|
||||||
|
|
||||||
|
it("TC-08: renders the Library heading", async () => {
|
||||||
|
renderBandPage();
|
||||||
|
const heading = await screen.findByText("Library");
|
||||||
|
expect(heading).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("TC-09: renders filter pills including All and Guitar", async () => {
|
||||||
|
renderBandPage();
|
||||||
|
const allPill = await screen.findByText("all");
|
||||||
|
const guitarPill = await screen.findByText("guitar");
|
||||||
|
expect(allPill).toBeTruthy();
|
||||||
|
expect(guitarPill).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useParams, Link } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getBand } from "../api/bands";
|
import { getBand } from "../api/bands";
|
||||||
@@ -21,34 +21,37 @@ interface SessionSummary {
|
|||||||
recording_count: number;
|
recording_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented";
|
||||||
|
|
||||||
|
const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"];
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function weekday(iso: string): string {
|
function formatDateLabel(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
|
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(12, 0, 0, 0);
|
||||||
|
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandPage() {
|
export function BandPage() {
|
||||||
const { bandId } = useParams<{ bandId: string }>();
|
const { bandId } = useParams<{ bandId: string }>();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [tab, setTab] = useState<"dates" | "search">("dates");
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
||||||
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
||||||
|
const [librarySearch, setLibrarySearch] = useState("");
|
||||||
// Search state
|
const [activePill, setActivePill] = useState<FilterPill>("all");
|
||||||
const [searchQ, setSearchQ] = useState("");
|
|
||||||
const [searchKey, setSearchKey] = useState("");
|
|
||||||
const [searchBpmMin, setSearchBpmMin] = useState("");
|
|
||||||
const [searchBpmMax, setSearchBpmMax] = useState("");
|
|
||||||
const [searchTagInput, setSearchTagInput] = useState("");
|
|
||||||
const [searchTags, setSearchTags] = useState<string[]>([]);
|
|
||||||
const [searchDirty, setSearchDirty] = useState(false);
|
|
||||||
|
|
||||||
const { data: band, isLoading } = useQuery({
|
const { data: band, isLoading } = useQuery({
|
||||||
queryKey: ["band", bandId],
|
queryKey: ["band", bandId],
|
||||||
@@ -59,35 +62,41 @@ export function BandPage() {
|
|||||||
const { data: sessions } = useQuery({
|
const { data: sessions } = useQuery({
|
||||||
queryKey: ["sessions", bandId],
|
queryKey: ["sessions", bandId],
|
||||||
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||||||
enabled: !!bandId && tab === "dates",
|
enabled: !!bandId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: unattributedSongs } = useQuery({
|
const { data: unattributedSongs } = useQuery({
|
||||||
queryKey: ["songs-unattributed", bandId],
|
queryKey: ["songs-unattributed", bandId],
|
||||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
|
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
|
||||||
enabled: !!bandId && tab === "dates",
|
enabled: !!bandId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search results — only fetch when user has triggered a search
|
const filteredSessions = useMemo(() => {
|
||||||
const searchParams = new URLSearchParams();
|
return (sessions ?? []).filter((s) => {
|
||||||
if (searchQ) searchParams.set("q", searchQ);
|
if (!librarySearch) return true;
|
||||||
if (searchKey) searchParams.set("key", searchKey);
|
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
|
||||||
if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin);
|
return haystack.includes(librarySearch.toLowerCase());
|
||||||
if (searchBpmMax) searchParams.set("bpm_max", searchBpmMax);
|
});
|
||||||
searchTags.forEach((t) => searchParams.append("tags", t));
|
}, [sessions, librarySearch]);
|
||||||
|
|
||||||
const { data: searchResults, isFetching: searchFetching } = useQuery({
|
const filteredUnattributed = useMemo(() => {
|
||||||
queryKey: ["songs-search", bandId, searchParams.toString()],
|
return (unattributedSongs ?? []).filter((song) => {
|
||||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?${searchParams}`),
|
const matchesSearch =
|
||||||
enabled: !!bandId && tab === "search" && searchDirty,
|
!librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase());
|
||||||
});
|
const matchesPill =
|
||||||
|
activePill === "all" ||
|
||||||
|
activePill === "commented" ||
|
||||||
|
song.tags.some((t) => t.toLowerCase() === activePill);
|
||||||
|
return matchesSearch && matchesPill;
|
||||||
|
});
|
||||||
|
}, [unattributedSongs, librarySearch, activePill]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
|
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setTitle("");
|
setNewTitle("");
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
|
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
|
||||||
@@ -152,533 +161,291 @@ export function BandPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag() {
|
|
||||||
const t = searchTagInput.trim();
|
|
||||||
if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]);
|
|
||||||
setSearchTagInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTag(t: string) {
|
|
||||||
setSearchTags((prev) => prev.filter((x) => x !== t));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
|
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
|
||||||
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||||
|
|
||||||
|
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "20px 32px", maxWidth: 760, margin: "0 auto" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
|
||||||
{/* ── Page header ───────────────────────────────────────── */}
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
<div>
|
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
||||||
<h1 style={{ color: "#eeeef2", fontSize: 17, fontWeight: 500, margin: "0 0 4px" }}>{band.name}</h1>
|
{/* Title row + search + actions */}
|
||||||
{band.genre_tags.length > 0 && (
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
|
||||||
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
|
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
|
||||||
{band.genre_tags.map((t: string) => (
|
Library
|
||||||
<span
|
</h1>
|
||||||
key={t}
|
|
||||||
style={{
|
{/* Search input */}
|
||||||
background: "rgba(140,90,220,0.1)",
|
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
|
||||||
color: "#a878e8",
|
<svg
|
||||||
fontSize: 10,
|
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
|
||||||
padding: "1px 7px",
|
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
|
||||||
borderRadius: 12,
|
>
|
||||||
}}
|
<circle cx="5.5" cy="5.5" r="3.5" />
|
||||||
>
|
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
|
||||||
{t}
|
</svg>
|
||||||
</span>
|
<input
|
||||||
))}
|
value={librarySearch}
|
||||||
</div>
|
onChange={(e) => setLibrarySearch(e.target.value)}
|
||||||
)}
|
placeholder="Search recordings, comments…"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 12px 7px 30px",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 7,
|
||||||
|
color: "#e2e2e8",
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginLeft: "auto", display: "flex", gap: 8, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={startScan}
|
||||||
|
disabled={scanning}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "1px solid rgba(255,255,255,0.09)",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
|
||||||
|
cursor: scanning ? "default" : "pointer",
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
||||||
|
style={{
|
||||||
|
background: "rgba(232,162,42,0.14)",
|
||||||
|
border: "1px solid rgba(232,162,42,0.28)",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#e8a22a",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "5px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
|
{/* Filter pills */}
|
||||||
<button
|
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
|
||||||
onClick={startScan}
|
{PILLS.map((pill) => {
|
||||||
disabled={scanning}
|
const active = activePill === pill;
|
||||||
style={{
|
return (
|
||||||
background: "none",
|
<button
|
||||||
border: "1px solid rgba(255,255,255,0.09)",
|
key={pill}
|
||||||
borderRadius: 6,
|
onClick={() => setActivePill(pill)}
|
||||||
color: "#4dba85",
|
style={{
|
||||||
cursor: scanning ? "default" : "pointer",
|
padding: "3px 10px",
|
||||||
padding: "6px 14px",
|
borderRadius: 20,
|
||||||
fontSize: 12,
|
cursor: "pointer",
|
||||||
fontFamily: "inherit",
|
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
|
||||||
opacity: scanning ? 0.6 : 1,
|
background: active ? "rgba(232,162,42,0.1)" : "transparent",
|
||||||
}}
|
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
|
||||||
>
|
fontSize: 11,
|
||||||
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
|
fontFamily: "inherit",
|
||||||
</button>
|
transition: "all 0.12s",
|
||||||
<button
|
textTransform: "capitalize",
|
||||||
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
}}
|
||||||
style={{
|
>
|
||||||
background: "rgba(232,162,42,0.14)",
|
{pill}
|
||||||
border: "1px solid rgba(232,162,42,0.28)",
|
</button>
|
||||||
borderRadius: 6,
|
);
|
||||||
color: "#e8a22a",
|
})}
|
||||||
cursor: "pointer",
|
|
||||||
padding: "6px 14px",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ New Song
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Scan feedback ─────────────────────────────────────── */}
|
{/* ── Scan feedback ─────────────────────────────────────── */}
|
||||||
{scanning && scanProgress && (
|
{scanning && scanProgress && (
|
||||||
<div
|
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
|
||||||
style={{
|
<div style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
|
||||||
background: "rgba(255,255,255,0.03)",
|
{scanProgress}
|
||||||
border: "1px solid rgba(255,255,255,0.07)",
|
</div>
|
||||||
borderRadius: 8,
|
|
||||||
color: "rgba(255,255,255,0.42)",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: "8px 14px",
|
|
||||||
marginBottom: 10,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scanProgress}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{scanMsg && (
|
{scanMsg && (
|
||||||
<div
|
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
|
||||||
style={{
|
<div style={{ background: "rgba(61,200,120,0.06)", border: "1px solid rgba(61,200,120,0.25)", borderRadius: 8, color: "#4dba85", fontSize: 12, padding: "8px 14px" }}>
|
||||||
background: "rgba(61,200,120,0.06)",
|
{scanMsg}
|
||||||
border: "1px solid rgba(61,200,120,0.25)",
|
|
||||||
borderRadius: 8,
|
|
||||||
color: "#4dba85",
|
|
||||||
fontSize: 12,
|
|
||||||
padding: "8px 14px",
|
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scanMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── New song form ─────────────────────────────────────── */}
|
|
||||||
{showCreate && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.025)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.07)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 20,
|
|
||||||
marginBottom: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", 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: "rgba(255,255,255,0.05)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
|
||||||
borderRadius: 7,
|
|
||||||
color: "#eeeef2",
|
|
||||||
marginBottom: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
disabled={!title}
|
|
||||||
style={{
|
|
||||||
background: "rgba(232,162,42,0.14)",
|
|
||||||
border: "1px solid rgba(232,162,42,0.28)",
|
|
||||||
borderRadius: 6,
|
|
||||||
color: "#e8a22a",
|
|
||||||
cursor: title ? "pointer" : "default",
|
|
||||||
padding: "7px 18px",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
opacity: title ? 1 : 0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowCreate(false); setError(null); }}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "1px solid rgba(255,255,255,0.09)",
|
|
||||||
borderRadius: 6,
|
|
||||||
color: "rgba(255,255,255,0.42)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "7px 18px",
|
|
||||||
fontSize: 13,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Tabs ──────────────────────────────────────────────── */}
|
{/* ── New song / upload form ─────────────────────────────── */}
|
||||||
<div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}>
|
{showCreate && (
|
||||||
{(["dates", "search"] as const).map((t) => (
|
<div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
|
||||||
<button
|
<div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 18 }}>
|
||||||
key={t}
|
{error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||||
onClick={() => setTab(t)}
|
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}>
|
||||||
style={{
|
Song title
|
||||||
background: "none",
|
</label>
|
||||||
border: "none",
|
<input
|
||||||
borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`,
|
value={newTitle}
|
||||||
color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)",
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
cursor: "pointer",
|
onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()}
|
||||||
padding: "8px 16px",
|
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", marginBottom: 12, fontSize: 14, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
||||||
fontSize: 13,
|
autoFocus
|
||||||
fontWeight: tab === t ? 600 : 400,
|
/>
|
||||||
marginBottom: -1,
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
fontFamily: "inherit",
|
<button
|
||||||
transition: "color 0.12s",
|
onClick={() => createMutation.mutate()}
|
||||||
}}
|
disabled={!newTitle}
|
||||||
>
|
style={{ background: "rgba(232,162,42,0.14)", border: "1px solid rgba(232,162,42,0.28)", borderRadius: 6, color: "#e8a22a", cursor: newTitle ? "pointer" : "default", padding: "7px 18px", fontWeight: 600, fontSize: 13, fontFamily: "inherit", opacity: newTitle ? 1 : 0.4 }}
|
||||||
{t === "dates" ? "By Date" : "Search"}
|
>
|
||||||
</button>
|
Create
|
||||||
))}
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => { setShowCreate(false); setError(null); }}
|
||||||
|
style={{ background: "none", border: "1px solid rgba(255,255,255,0.09)", borderRadius: 6, color: "rgba(255,255,255,0.42)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontFamily: "inherit" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── By Date tab ───────────────────────────────────────── */}
|
{/* ── Scrollable content ────────────────────────────────── */}
|
||||||
{tab === "dates" && (
|
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
|
||||||
<div style={{ display: "grid", gap: 4 }}>
|
|
||||||
{sessions?.map((s) => (
|
{/* Sessions — one date group per session */}
|
||||||
|
{filteredSessions.map((s) => (
|
||||||
|
<div key={s.id} style={{ marginTop: 18 }}>
|
||||||
|
{/* Date group header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||||
|
{formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||||
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||||
|
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session row */}
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
|
||||||
to={`/bands/${bandId}/sessions/${s.id}`}
|
to={`/bands/${bandId}/sessions/${s.id}`}
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.02)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "13px 16px",
|
|
||||||
textDecoration: "none",
|
|
||||||
color: "#eeeef2",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: 11,
|
||||||
|
padding: "9px 13px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.04)",
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "pointer",
|
||||||
transition: "background 0.12s, border-color 0.12s",
|
transition: "background 0.12s, border-color 0.12s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)";
|
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)";
|
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)";
|
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
{/* Session name */}
|
||||||
<span
|
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
style={{
|
{s.label ?? formatDate(s.date)}
|
||||||
fontFamily: "monospace",
|
</span>
|
||||||
color: "rgba(255,255,255,0.28)",
|
|
||||||
fontSize: 10,
|
{/* Recording count */}
|
||||||
flexShrink: 0,
|
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
}}
|
{s.recording_count}
|
||||||
>
|
|
||||||
{weekday(s.date)}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontWeight: 500, color: "#d8d8e4" }}>{formatDate(s.date)}</span>
|
|
||||||
{s.label && (
|
|
||||||
<span style={{ color: "#4dba85", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "rgba(255,255,255,0.28)",
|
|
||||||
fontSize: 12,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
|
||||||
|
|
||||||
{sessions?.length === 0 && !unattributedSongs?.length && (
|
|
||||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "8px 0" }}>
|
|
||||||
No sessions yet. Scan Nextcloud or create a song to get started.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Unattributed songs */}
|
|
||||||
{!!unattributedSongs?.length && (
|
|
||||||
<div style={{ marginTop: sessions?.length ? 24 : 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "rgba(255,255,255,0.2)",
|
|
||||||
fontSize: 10,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
letterSpacing: 1,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: 8,
|
|
||||||
paddingLeft: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unattributed Recordings
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "grid", gap: 4 }}>
|
|
||||||
{unattributedSongs.map((song) => (
|
|
||||||
<Link
|
|
||||||
key={song.id}
|
|
||||||
to={`/bands/${bandId}/songs/${song.id}`}
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.02)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "13px 16px",
|
|
||||||
textDecoration: "none",
|
|
||||||
color: "#eeeef2",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
|
|
||||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
|
||||||
{song.tags.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
style={{
|
|
||||||
background: "rgba(61,200,120,0.08)",
|
|
||||||
color: "#4dba85",
|
|
||||||
fontSize: 9,
|
|
||||||
padding: "1px 6px",
|
|
||||||
borderRadius: 3,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "2px 6px",
|
|
||||||
marginRight: 8,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{song.status}
|
|
||||||
</span>
|
|
||||||
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Search tab ────────────────────────────────────────── */}
|
|
||||||
{tab === "search" && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.025)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.06)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
|
|
||||||
Title
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={searchQ}
|
|
||||||
onChange={(e) => setSearchQ(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }}
|
|
||||||
placeholder="Search by name…"
|
|
||||||
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
|
|
||||||
Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={searchKey}
|
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
placeholder="e.g. Am, C, F#"
|
|
||||||
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
|
|
||||||
BPM min
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={searchBpmMin}
|
|
||||||
onChange={(e) => setSearchBpmMin(e.target.value)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="e.g. 80"
|
|
||||||
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
|
|
||||||
BPM max
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={searchBpmMax}
|
|
||||||
onChange={(e) => setSearchBpmMax(e.target.value)}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="e.g. 140"
|
|
||||||
style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 10 }}>
|
|
||||||
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
|
|
||||||
Tags (must have all)
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 6 }}>
|
|
||||||
{searchTags.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
style={{
|
|
||||||
background: "rgba(61,200,120,0.08)",
|
|
||||||
color: "#4dba85",
|
|
||||||
fontSize: 11,
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: 12,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
<button
|
|
||||||
onClick={() => removeTag(t)}
|
|
||||||
style={{ background: "none", border: "none", color: "#4dba85", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1, fontFamily: "inherit" }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
|
||||||
<input
|
|
||||||
value={searchTagInput}
|
|
||||||
onChange={(e) => setSearchTagInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && addTag()}
|
|
||||||
placeholder="Add tag…"
|
|
||||||
style={{ flex: 1, padding: "6px 10px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 12, fontFamily: "inherit", outline: "none" }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={addTag}
|
|
||||||
style={{ background: "none", border: "1px solid rgba(255,255,255,0.09)", borderRadius: 6, color: "rgba(255,255,255,0.42)", cursor: "pointer", padding: "6px 10px", fontSize: 12, fontFamily: "inherit" }}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }}
|
|
||||||
style={{
|
|
||||||
background: "rgba(232,162,42,0.14)",
|
|
||||||
border: "1px solid rgba(232,162,42,0.28)",
|
|
||||||
borderRadius: 6,
|
|
||||||
color: "#e8a22a",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "7px 18px",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{searchFetching && <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Searching…</p>}
|
{/* Unattributed recordings */}
|
||||||
{!searchFetching && searchDirty && (
|
{filteredUnattributed.length > 0 && (
|
||||||
<div style={{ display: "grid", gap: 6 }}>
|
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
|
||||||
{searchResults?.map((song) => (
|
{/* Section header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||||
|
Unattributed
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||||
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||||
|
{filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: 3 }}>
|
||||||
|
{filteredUnattributed.map((song) => (
|
||||||
<Link
|
<Link
|
||||||
key={song.id}
|
key={song.id}
|
||||||
to={`/bands/${bandId}/songs/${song.id}`}
|
to={`/bands/${bandId}/songs/${song.id}`}
|
||||||
style={{
|
style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 13px", borderRadius: 8, background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)", textDecoration: "none", transition: "background 0.12s, border-color 0.12s" }}
|
||||||
background: "rgba(255,255,255,0.02)",
|
onMouseEnter={(e) => {
|
||||||
border: "1px solid rgba(255,255,255,0.05)",
|
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||||
borderRadius: 8,
|
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||||
padding: "13px 16px",
|
}}
|
||||||
textDecoration: "none",
|
onMouseLeave={(e) => {
|
||||||
color: "#eeeef2",
|
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||||
display: "flex",
|
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
|
<div style={{ fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginBottom: 3 }}>
|
||||||
|
{song.title}
|
||||||
|
</div>
|
||||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||||
{song.tags.map((t) => (
|
{song.tags.map((t) => (
|
||||||
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
|
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
{song.global_key && (
|
{song.global_key && (
|
||||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span>
|
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||||
|
{song.global_key}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{song.global_bpm && (
|
{song.global_bpm && (
|
||||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span>
|
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||||
|
{song.global_bpm.toFixed(0)} BPM
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
||||||
<span style={{ background: "rgba(255,255,255,0.05)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
|
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
|
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
|
||||||
</div>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{searchResults?.length === 0 && (
|
|
||||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{!searchDirty && (
|
)}
|
||||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p>
|
|
||||||
)}
|
{/* Empty state */}
|
||||||
</div>
|
{!hasResults && (
|
||||||
)}
|
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
|
||||||
|
{librarySearch
|
||||||
|
? "No results match your search."
|
||||||
|
: "No sessions yet. Scan Nextcloud or create a song to get started."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useParams, Link } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
@@ -24,10 +24,29 @@ interface SessionDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||||
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeWaveBars(seed: string): number[] {
|
||||||
|
let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337);
|
||||||
|
return Array.from({ length: 14 }, () => {
|
||||||
|
s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0;
|
||||||
|
return Math.max(15, Math.floor((s / 0xffffffff) * 100));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniWaveBars({ seed }: { seed: string }) {
|
||||||
|
const bars = useMemo(() => computeWaveBars(seed), [seed]);
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 34, flexShrink: 0 }}>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<div key={i} style={{ width: 2, background: "rgba(255,255,255,0.11)", borderRadius: 1, height: `${h}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionPage() {
|
export function SessionPage() {
|
||||||
const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>();
|
const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -165,9 +184,12 @@ export function SessionPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
|
||||||
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
|
<MiniWaveBars seed={song.id} />
|
||||||
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
|
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
|
||||||
|
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
|
||||||
|
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const apiBase = process.env.API_URL ?? "http://localhost:8000";
|
||||||
|
const wsBase = apiBase.replace(/^http/, "ws");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
"/api": { target: apiBase, changeOrigin: true },
|
||||||
"/ws": { target: "ws://localhost:8000", ws: true },
|
"/ws": { target: wsBase, ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import json
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
def extract_peaks(audio: np.ndarray, num_points: int = 1000) -> list[float]:
|
def extract_peaks(audio: np.ndarray, num_points: int = 500) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Downsample audio to `num_points` RMS+peak values for waveform display.
|
Downsample audio to `num_points` RMS+peak values for waveform display.
|
||||||
Returns a flat list of [peak, peak, ...] normalized to 0-1.
|
Returns a flat list of [peak, peak, ...] normalized to 0-1.
|
||||||
|
|||||||
Reference in New Issue
Block a user