12 Commits

Author SHA1 Message Date
Mistral Vibe
fdf9f52f6f Rework song player view to match design system
- New split layout: waveform/transport/queue left, comment panel right
- Avatar pins above waveform positioned by timestamp with hover tooltips
- Transport bar: speed selector, ±30s skip, 46px amber play/pause, volume
- Comment compose: live timestamp pill, suggestion/issue/keeper tag buttons
- Comment list: per-author colour avatars, amber timestamp seek chips,
  playhead-proximity highlight, delete only shown on own comments
- Queue panel showing other songs in the same session
- Waveform colours updated to amber/dim palette (104px height)
- Add GET /songs/{song_id} endpoint for song metadata
- Add tag field to SongComment (model, schema, router, migration 0005)
- Fix migration 0005 down_revision to use short ID "0004"
- Fix ESLint no-unused-expressions in keyboard shortcut handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:14:56 +02:00
Mistral Vibe
a31f7db619 Fix API dev: use pip editable install instead of uv run
uv run spawns uvicorn, which uses multiprocessing.spawn for hot reload.
The spawned subprocess starts a fresh Python interpreter that bypasses
uv's venv activation — so it never sees the venv's packages.

Fix: use a standalone python:3.12-slim dev stage with pip install -e .
directly into the system Python. No venv means the spawn subprocess uses
the same interpreter with the same packages. The editable install creates
a .pth file pointing to /app/src, so the mounted host source is live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:32:27 +02:00
Mistral Vibe
ba90f581ae Remove --reload-dir from API dev CMD
uvicorn's path check for --reload-dir fails in Podman rootless even
though uv sync can read the same path. Drop the flag — the editable
install already points uvicorn's watcher at /app/src implicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:21:52 +02:00
Mistral Vibe
a8cbd333d2 Fix dev container startup for both API and web
API:
- Add python symlink (python3-slim has no bare 'python') so uv doesn't
  invalidate the baked venv on every container start
- Copy src/ before uv sync so hatchling installs rehearsalhub as a
  proper editable install (.pth pointing to /app/src) — previously
  sync ran with no source present, producing a broken empty wheel
- Remove ENV PYTHONPATH workaround (no longer needed with correct install)
- Add --reload-dir /app/src to scope uvicorn's file watcher to the
  mounted source directory

Web:
- Add COPY . . after npm install so index.html and vite.config.ts are
  baked into the image — without them Vite ignored port config and fell
  back to 5173 with no entry point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:14:21 +02:00
Mistral Vibe
261942be03 Fix dev compose: API PYTHONPATH and web volume permissions
API: bake ENV PYTHONPATH=/app/src into the development Dockerfile stage
so it's available to uvicorn's WatchFiles reloader subprocess — relying
on compose env vars isn't reliable across process forks.

Web: replace ./web:/app bind mount (caused EACCES in Podman rootless due
to UID mismatch) with ./web/src:/app/src — this preserves the container's
package.json and node_modules while still giving Vite live access to
source files for HMR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:02:36 +02:00
Mistral Vibe
4358461107 Fix API ModuleNotFoundError in dev compose
uv sync runs before the source is present in the image, so the local
package install is broken. Set PYTHONPATH=/app/src so Python finds
rehearsalhub directly from the mounted source volume — same approach
the worker Dockerfile already uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:53:41 +02:00
Mistral Vibe
3a7d8de69e Merge feature/dev-workflow into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:49:52 +02:00
Mistral Vibe
44503cca30 Add hot-reload dev environment via docker-compose.dev.yml
- web/Dockerfile: add `development` stage that installs deps and runs
  `vite dev --host 0.0.0.0`; source is mounted at runtime so edits
  reflect immediately without rebuilding the image
- web/vite.config.ts: read proxy target from API_URL env var
  (falls back to localhost:8000 for outside-compose usage)
- docker-compose.dev.yml: lightweight compose for development
  - api uses existing `development` target (uvicorn --reload)
  - web uses new `development` target with ./web mounted as volume
    and an anonymous volume to preserve container node_modules
  - worker and nc-watcher omitted (not needed for UI work)
  - separate pg_data_dev volume keeps dev DB isolated from prod

Usage:
  podman-compose -f docker-compose.dev.yml up --build

Frontend hot-reloads at http://localhost:3000
API auto-reloads at http://localhost:8000

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:49:33 +02:00
Mistral Vibe
c562c3da4a Merge feature/ui-refinement into main
- Library view redesigned to match mockup: unified view with search
  input, filter pills, date-group headers, and recording-row style
- Mini waveform bars moved to SessionPage individual recording rows
- Play buttons removed from Library session rows
- Fixed Invalid Date for API datetime strings (slice to date part)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:45:38 +02:00
Mistral Vibe
659598913b Remove play button from Library session rows
Play buttons don't make sense at the session level since sessions
group multiple recordings. Removed from both session rows and
unattributed song rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:44:02 +02:00
Mistral Vibe
013a2fc2d6 Fix Invalid Date for datetime strings from API
The API returns dates as "2024-12-11T00:00:00" (datetime, no timezone),
not bare "2024-12-11". Appending T12:00:00 directly produced an invalid
string. Use .slice(0, 10) to extract the date part first before parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:40:38 +02:00
Mistral Vibe
b09094658c Redesign Library view to match mockup spec
- Replace band-name header + tab structure (By Date / Search) with a
  unified Library view: title, inline search input, filter pills
  (All / instrument / Commented), and date-group headers
- Session rows now use the recording-row card style (play circle,
  mono filename, recording count)
- Move mini waveform bars from session list to individual recording
  rows in SessionPage, where they correspond to a single track
- Fix Invalid Date by appending T12:00:00 when parsing date-only
  ISO strings in both BandPage and SessionPage
- Update tests: drop tab assertions (TC-07), add Library heading
  (TC-08) and filter pill (TC-09) checks, update upload button label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:37:49 +02:00
14 changed files with 1401 additions and 752 deletions

View File

@@ -2,11 +2,15 @@ FROM python:3.12-slim AS base
WORKDIR /app
RUN pip install uv
FROM base AS development
FROM python:3.12-slim AS development
WORKDIR /app
COPY pyproject.toml .
RUN uv sync
COPY . .
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
COPY src/ src/
# Install directly into system Python — no venv, so uvicorn's multiprocessing.spawn
# 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
COPY pyproject.toml .

View 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")

View File

@@ -207,6 +207,7 @@ class SongComment(Base):
)
body: Mapped[str] = mapped_column(Text, nullable=False)
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(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@@ -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)
async def update_song(
song_id: uuid.UUID,
@@ -264,7 +282,7 @@ async def create_comment(
):
await _assert_song_membership(song_id, current_member.id, 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)
return SongCommentRead.from_model(comment)

View File

@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict
class SongCommentCreate(BaseModel):
body: str
timestamp: float | None = None
tag: str | None = None
class SongCommentRead(BaseModel):
@@ -21,6 +22,7 @@ class SongCommentRead(BaseModel):
author_name: str
author_avatar_url: str | None
timestamp: float | None
tag: str | None
created_at: datetime
@classmethod
@@ -33,5 +35,6 @@ class SongCommentRead(BaseModel):
author_name=getattr(getattr(c, "author"), "display_name"),
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
timestamp=getattr(c, "timestamp"),
tag=getattr(c, "tag", None),
created_at=getattr(c, "created_at"),
)

View File

@@ -1,17 +1,67 @@
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:
build:
context: ./api
target: development
volumes:
- ./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:
- "8000:8000"
networks:
- rh_net
depends_on:
db:
condition: service_healthy
audio-worker:
web:
build:
context: ./web
target: development
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:
volumes:
- ./watcher/src:/app/src
networks:
rh_net:
driver: bridge
volumes:
pg_data_dev:

View File

@@ -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
WORKDIR /app
COPY package*.json ./

View File

@@ -23,6 +23,7 @@ export function useWaveform(
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const wasPlayingRef = useRef(false);
const markersRef = useRef<CommentMarker[]>([]);
@@ -31,12 +32,12 @@ export function useWaveform(
const ws = WaveSurfer.create({
container: containerRef.current,
waveColor: "#2A3050",
progressColor: "#F0A840",
cursorColor: "#FFD080",
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
barWidth: 2,
barRadius: 2,
height: 80,
height: 104,
normalize: true,
});
@@ -45,6 +46,7 @@ export function useWaveform(
ws.on("ready", () => {
setIsReady(true);
setDuration(ws.getDuration());
options.onReady?.(ws.getDuration());
// Reset playing state when switching versions
setIsPlaying(false);
@@ -141,7 +143,7 @@ export function useWaveform(
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 {

View File

@@ -43,14 +43,13 @@ const renderBandPage = () =>
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
describe("BandPage — Library view (TC-01 to TC-09)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("TC-01: does not render a member list", async () => {
renderBandPage();
// Allow queries to settle
await new Promise((r) => setTimeout(r, 50));
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 () => {
renderBandPage();
// Sessions appear after the query resolves
const sessionEl = await screen.findByText("Late Night Jam");
expect(sessionEl).toBeTruthy();
});
@@ -81,17 +79,30 @@ describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
expect(btn).toBeTruthy();
});
it("TC-06: renders the + New Song button", async () => {
it("TC-06: renders the + Upload button", async () => {
renderBandPage();
const btn = await screen.findByText(/\+ new song/i);
const btn = await screen.findByText(/\+ upload/i);
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();
const byDate = await screen.findByText(/by date/i);
const search = await screen.findByText(/^search$/i);
expect(byDate).toBeTruthy();
expect(search).toBeTruthy();
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/by date/i)).toBeNull();
expect(screen.queryByText(/^search$/i)).toBeNull();
});
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();
});
});

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
@@ -21,34 +21,37 @@ interface SessionSummary {
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 {
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" });
}
function weekday(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
function formatDateLabel(iso: string): string {
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() {
const { bandId } = useParams<{ bandId: string }>();
const qc = useQueryClient();
const [tab, setTab] = useState<"dates" | "search">("dates");
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [newTitle, setNewTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
// Search state
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 [librarySearch, setLibrarySearch] = useState("");
const [activePill, setActivePill] = useState<FilterPill>("all");
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
@@ -59,35 +62,41 @@ export function BandPage() {
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId && tab === "dates",
enabled: !!bandId,
});
const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId],
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 searchParams = new URLSearchParams();
if (searchQ) searchParams.set("q", searchQ);
if (searchKey) searchParams.set("key", searchKey);
if (searchBpmMin) searchParams.set("bpm_min", searchBpmMin);
if (searchBpmMax) searchParams.set("bpm_max", searchBpmMax);
searchTags.forEach((t) => searchParams.append("tags", t));
const filteredSessions = useMemo(() => {
return (sessions ?? []).filter((s) => {
if (!librarySearch) return true;
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
return haystack.includes(librarySearch.toLowerCase());
});
}, [sessions, librarySearch]);
const { data: searchResults, isFetching: searchFetching } = useQuery({
queryKey: ["songs-search", bandId, searchParams.toString()],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?${searchParams}`),
enabled: !!bandId && tab === "search" && searchDirty,
});
const filteredUnattributed = useMemo(() => {
return (unattributedSongs ?? []).filter((song) => {
const matchesSearch =
!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({
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }),
mutationFn: () => api.post(`/bands/${bandId}/songs`, { title: newTitle }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
setShowCreate(false);
setTitle("");
setNewTitle("");
setError(null);
},
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 (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
return (
<div style={{ padding: "20px 32px", maxWidth: 760, margin: "0 auto" }}>
{/* ── Page header ───────────────────────────────────────── */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div>
<h1 style={{ color: "#eeeef2", fontSize: 17, fontWeight: 500, margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
{band.genre_tags.map((t: string) => (
<span
key={t}
style={{
background: "rgba(140,90,220,0.1)",
color: "#a878e8",
fontSize: 10,
padding: "1px 7px",
borderRadius: 12,
}}
>
{t}
</span>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
{/* ── Header ─────────────────────────────────────────────── */}
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{/* Title row + search + actions */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
Library
</h1>
{/* Search input */}
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
<svg
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
>
<circle cx="5.5" cy="5.5" r="3.5" />
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
</svg>
<input
value={librarySearch}
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 style={{ 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: "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
opacity: scanning ? 0.6 : 1,
}}
>
{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: "6px 14px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
}}
>
+ New Song
</button>
{/* Filter pills */}
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
{PILLS.map((pill) => {
const active = activePill === pill;
return (
<button
key={pill}
onClick={() => setActivePill(pill)}
style={{
padding: "3px 10px",
borderRadius: 20,
cursor: "pointer",
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
background: active ? "rgba(232,162,42,0.1)" : "transparent",
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
fontSize: 11,
fontFamily: "inherit",
transition: "all 0.12s",
textTransform: "capitalize",
}}
>
{pill}
</button>
);
})}
</div>
</div>
{/* ── Scan feedback ─────────────────────────────────────── */}
{scanning && scanProgress && (
<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",
marginBottom: 10,
fontFamily: "monospace",
}}
>
{scanProgress}
<div style={{ padding: "10px 26px 0", flexShrink: 0 }}>
<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" }}>
{scanProgress}
</div>
</div>
)}
{scanMsg && (
<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",
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 style={{ padding: "10px 26px 0", flexShrink: 0 }}>
<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" }}>
{scanMsg}
</div>
</div>
)}
{/* ── Tabs ──────────────────────────────────────────────── */}
<div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}>
{(["dates", "search"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: "none",
border: "none",
borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`,
color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)",
cursor: "pointer",
padding: "8px 16px",
fontSize: 13,
fontWeight: tab === t ? 600 : 400,
marginBottom: -1,
fontFamily: "inherit",
transition: "color 0.12s",
}}
>
{t === "dates" ? "By Date" : "Search"}
</button>
))}
</div>
{/* ── New song / upload form ─────────────────────────────── */}
{showCreate && (
<div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
<div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 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={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && newTitle && 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={!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 }}
>
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>
)}
{/* ── By Date tab ───────────────────────────────────────── */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 4 }}>
{sessions?.map((s) => (
{/* ── Scrollable content ────────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
{/* 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
key={s.id}
to={`/bands/${bandId}/sessions/${s.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,
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",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)";
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
}}
onMouseLeave={(e) => {
(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 }}>
<span
style={{
fontFamily: "monospace",
color: "rgba(255,255,255,0.28)",
fontSize: 10,
flexShrink: 0,
}}
>
{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" : ""}
{/* Session name */}
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label ?? formatDate(s.date)}
</span>
{/* Recording count */}
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{s.recording_count}
</span>
</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>
))}
{searchFetching && <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Searching</p>}
{!searchFetching && searchDirty && (
<div style={{ display: "grid", gap: 6 }}>
{searchResults?.map((song) => (
{/* Unattributed recordings */}
{filteredUnattributed.length > 0 && (
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
{/* 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
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,
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" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<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" }}>
{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 && (
<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 && (
<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 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>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
{searchResults?.length === 0 && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div>
)}
{!searchDirty && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p>
)}
</div>
)}
</div>
)}
{/* Empty state */}
{!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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
@@ -24,10 +24,29 @@ interface SessionDetail {
}
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" });
}
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() {
const { bandId, sessionId } = useParams<{ bandId: string; sessionId: string }>();
const qc = useQueryClient();
@@ -165,9 +184,12 @@ export function SessionPage() {
)}
</div>
</div>
<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 style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<MiniWaveBars seed={song.id} />
<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>
</Link>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const apiBase = process.env.API_URL ?? "http://localhost:8000";
const wsBase = apiBase.replace(/^http/, "ws");
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true },
"/ws": { target: "ws://localhost:8000", ws: true },
"/api": { target: apiBase, changeOrigin: true },
"/ws": { target: wsBase, ws: true },
},
},
test: {

View File

@@ -7,7 +7,7 @@ import json
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.
Returns a flat list of [peak, peak, ...] normalized to 0-1.