5 Commits

Author SHA1 Message Date
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
6 changed files with 388 additions and 527 deletions

View File

@@ -1,17 +1,68 @@
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:/app
- /app/node_modules
environment:
API_URL: http://api:8000
ports:
- "3000:3000"
networks:
- rh_net
depends_on:
- api
networks:
rh_net:
driver: bridge
nc-watcher:
volumes: volumes:
- ./watcher/src:/app/src pg_data_dev:

View File

@@ -1,3 +1,10 @@
FROM node:20-alpine AS development
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
# Source is mounted as a volume at runtime — node_modules stays in 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 ./

View File

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

View File

@@ -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));
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,
}); });
}, [sessions, librarySearch]);
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({ 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,46 +161,53 @@ 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,
}}
> >
{t} <circle cx="5.5" cy="5.5" r="3.5" />
</span> <path d="M8.5 8.5l3 3" strokeLinecap="round" />
))} </svg>
</div> <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>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}> <div style={{ marginLeft: "auto", display: "flex", gap: 8, flexShrink: 0 }}>
<button <button
onClick={startScan} onClick={startScan}
disabled={scanning} disabled={scanning}
@@ -199,12 +215,11 @@ export function BandPage() {
background: "none", background: "none",
border: "1px solid rgba(255,255,255,0.09)", border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6, borderRadius: 6,
color: "#4dba85", color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer", cursor: scanning ? "default" : "pointer",
padding: "6px 14px", padding: "5px 12px",
fontSize: 12, fontSize: 12,
fontFamily: "inherit", fontFamily: "inherit",
opacity: scanning ? 0.6 : 1,
}} }}
> >
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"} {scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
@@ -217,468 +232,220 @@ export function BandPage() {
borderRadius: 6, borderRadius: 6,
color: "#e8a22a", color: "#e8a22a",
cursor: "pointer", cursor: "pointer",
padding: "6px 14px", padding: "5px 12px",
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
fontFamily: "inherit", fontFamily: "inherit",
}} }}
> >
+ New Song + Upload
</button> </button>
</div> </div>
</div> </div>
{/* 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 ─────────────────────────────────────── */} {/* ── 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)",
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} {scanProgress}
</div> </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)",
border: "1px solid rgba(61,200,120,0.25)",
borderRadius: 8,
color: "#4dba85",
fontSize: 12,
padding: "8px 14px",
marginBottom: 14,
}}
>
{scanMsg} {scanMsg}
</div> </div>
</div>
)} )}
{/* ── New song form ─────────────────────────────────────── */} {/* ── New song / upload form ─────────────────────────────── */}
{showCreate && ( {showCreate && (
<div <div style={{ padding: "14px 26px 0", flexShrink: 0 }}>
style={{ <div style={{ background: "rgba(255,255,255,0.025)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, padding: 18 }}>
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>} {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 }}> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}>
Song title Song title
</label> </label>
<input <input
value={title} value={newTitle}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} onKeyDown={(e) => e.key === "Enter" && newTitle && createMutation.mutate()}
style={{ 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" }}
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 autoFocus
/> />
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!title} disabled={!newTitle}
style={{ 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 }}
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 Create
</button> </button>
<button <button
onClick={() => { setShowCreate(false); setError(null); }} onClick={() => { setShowCreate(false); setError(null); }}
style={{ 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" }}
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 Cancel
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
{/* ── Tabs ──────────────────────────────────────────────── */} {/* ── Scrollable content ────────────────────────────────── */}
<div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}> <div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
{(["dates", "search"] as const).map((t) => (
<button {/* Sessions — one date group per session */}
key={t} {filteredSessions.map((s) => (
onClick={() => setTab(t)} <div key={s.id} style={{ marginTop: 18 }}>
style={{ {/* Date group header */}
background: "none", <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
border: "none", <span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`, {formatDateLabel(s.date)}{s.label ? `${s.label}` : ""}
color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)", </span>
cursor: "pointer", <div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
padding: "8px 16px", <span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
fontSize: 13, {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
fontWeight: tab === t ? 600 : 400, </span>
marginBottom: -1,
fontFamily: "inherit",
transition: "color 0.12s",
}}
>
{t === "dates" ? "By Date" : "Search"}
</button>
))}
</div> </div>
{/* ── By Date tab ───────────────────────────────────────── */} {/* Session row */}
{tab === "dates" && (
<div style={{ display: "grid", gap: 4 }}>
{sessions?.map((s) => (
<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",
color: "rgba(255,255,255,0.28)",
fontSize: 10,
flexShrink: 0,
}}
>
{weekday(s.date)}
</span> </span>
<span style={{ fontWeight: 500, color: "#d8d8e4" }}>{formatDate(s.date)}</span>
{s.label && ( {/* Recording count */}
<span style={{ color: "#4dba85", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{s.label} {s.recording_count}
</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>
</div>
))} ))}
{sessions?.length === 0 && !unattributedSongs?.length && ( {/* Unattributed recordings */}
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "8px 0" }}> {filteredUnattributed.length > 0 && (
No sessions yet. Scan Nextcloud or create a song to get started. <div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
</p> {/* 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 songs */} Unattributed
{!!unattributedSongs?.length && ( </span>
<div style={{ marginTop: sessions?.length ? 24 : 0 }}> <div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<div <span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
style={{ {filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
color: "rgba(255,255,255,0.2)", </span>
fontSize: 10,
fontFamily: "monospace",
letterSpacing: 1,
textTransform: "uppercase",
marginBottom: 8,
paddingLeft: 2,
}}
>
Unattributed Recordings
</div> </div>
<div style={{ display: "grid", gap: 4 }}>
{unattributedSongs.map((song) => ( <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 <span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
key={t}
style={{
background: "rgba(61,200,120,0.08)",
color: "#4dba85",
fontSize: 9,
padding: "1px 6px",
borderRadius: 3,
fontFamily: "monospace",
}}
>
{t} {t}
</span> </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) => (
<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>
))}
{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 && ( </div>
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
)}
</div> </div>
)} )}
{!searchDirty && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p> {/* 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>
)}
</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 { 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,10 +184,13 @@ export function SessionPage() {
)} )}
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<MiniWaveBars seed={song.id} />
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}> <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> <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" : ""} {song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div> </div>
</div>
</Link> </Link>
))} ))}
{session.songs.length === 0 && ( {session.songs.length === 0 && (

View File

@@ -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: {