Merge pull request 'development' (#1) from development into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-10 07:57:43 +00:00
39 changed files with 3377 additions and 3567 deletions

View File

@@ -1,78 +0,0 @@
.PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format
up: validate-env
docker compose up -d
validate-env:
bash scripts/validate-env.sh
down:
docker compose down
build: check
docker compose build
logs:
docker compose logs -f
# ── Database ──────────────────────────────────────────────────────────────────
migrate:
docker compose exec api alembic upgrade head
migrate-auto:
docker compose exec api alembic revision --autogenerate -m "$(m)"
# ── Setup ─────────────────────────────────────────────────────────────────────
setup: validate-env up
@echo "Waiting for Nextcloud to initialize (this can take ~60s)..."
@sleep 60
bash scripts/nc-setup.sh
bash scripts/seed.sh
# ── Testing ───────────────────────────────────────────────────────────────────
test: test-api test-worker test-watcher
test-api:
cd api && uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing
test-worker:
cd worker && uv run pytest tests/ -v --cov=src/worker --cov-report=term-missing
test-watcher:
cd watcher && uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing
test-integration:
cd api && uv run pytest tests/integration/ -v -m integration
# ── Linting & type checking ───────────────────────────────────────────────────
# check: run all linters + type checkers locally (fast, no Docker)
check: lint typecheck-web
lint:
cd api && uv run ruff check src/ tests/ && uv run mypy src/
cd worker && uv run ruff check src/ tests/
cd watcher && uv run ruff check src/ tests/
cd web && npm run lint
typecheck-web:
cd web && npm run typecheck
format:
cd api && uv run ruff format src/ tests/
cd worker && uv run ruff format src/ tests/
cd watcher && uv run ruff format src/ tests/
# ── Dev helpers ───────────────────────────────────────────────────────────────
shell-api:
docker compose exec api bash
shell-db:
docker compose exec db psql -U $${POSTGRES_USER} -d $${POSTGRES_DB}
shell-redis:
docker compose exec redis redis-cli

136
PLAN_waveform_precompute.md Normal file
View File

@@ -0,0 +1,136 @@
# Plan: Waveform Pre-computation
**Branch:** `feature/waveform-precompute`
**Goal:** Store waveform peaks in the database during indexing so WaveSurfer renders
the waveform instantly (no waiting for audio decode), and show a mini-waveform in
the library/overview song list.
## Background
WaveSurfer v7 supports `ws.load(url, channelData)` — when pre-computed peaks are
passed as a `Float32Array[]`, the waveform renders immediately and audio streams in
the background. Currently the frontend calls `ws.load(url)` which blocks until the
full audio is decoded.
The worker already generates a 500-point peaks JSON file (`waveform_url`), but:
- It is stored as a file on disk, not inline in the DB
- The frontend never reads it (the `peaksUrl` option in `useWaveform` is wired to
nothing)
## Architecture Decision
Add two JSONB columns to `audio_versions`:
- `waveform_peaks` — 500 points, returned inline with version data, passed to WaveSurfer
- `waveform_peaks_mini` — 100 points, returned inline, used for SVG mini-waveform in
library/song list
This eliminates a separate HTTP round-trip and lets the UI render the waveform the
moment the page loads.
---
## Checklist
### Backend
#### B1 — DB: Peaks columns + Alembic migration
- [ ] Write migration test: after upgrade, `audio_versions` table has `waveform_peaks`
and `waveform_peaks_mini` JSONB columns
- [ ] Create `api/alembic/versions/0006_waveform_peaks_in_db.py`
- [ ] Add `waveform_peaks` and `waveform_peaks_mini` JSONB columns to `AudioVersion`
model in `api/src/rehearsalhub/db/models.py`
#### B2 — Worker: Generate and store both peak resolutions
- [ ] Write unit tests for `extract_peaks()` in `worker/tests/test_waveform.py`:
- Returns exactly `num_points` values
- All values in [0.0, 1.0]
- Empty audio returns list of zeros (no crash)
- 100-point and 500-point both work
- [ ] Update `handle_transcode` in `worker/src/worker/main.py`:
- Generate `peaks_500 = extract_peaks(audio, 500)`
- Generate `peaks_100 = extract_peaks(audio, 100)`
- Store both on `AudioVersion` DB row
- [ ] Write integration test: after `handle_transcode`, row has non-null
`waveform_peaks` (len 500) and `waveform_peaks_mini` (len 100)
#### B3 — API Schema: Expose peaks in `AudioVersionRead`
- [ ] Write serialization test: `AudioVersionRead.model_validate(orm_obj)` includes
`waveform_peaks: list[float] | None` and `waveform_peaks_mini: list[float] | None`
- [ ] Update `api/src/rehearsalhub/schemas/audio_version.py` — add both fields
#### B4 — API Router: `/waveform` endpoint reads from DB
- [ ] Write endpoint tests:
- `GET /versions/{id}/waveform` returns `{"data": [...500 floats...]}` from DB
- `GET /versions/{id}/waveform?resolution=mini` returns 100-point peaks
- 404 when version has no peaks yet
- [ ] Update `api/src/rehearsalhub/routers/versions.py` — read from
`version.waveform_peaks` / `version.waveform_peaks_mini` instead of file I/O
#### B5 — API: Peaks inline on versions list (verify, no change expected)
- [ ] Write test: `GET /songs/{id}/versions` response includes `waveform_peaks` and
`waveform_peaks_mini` on each version object
- [ ] Confirm no router change needed (schema update in B3 is sufficient)
---
### Frontend
#### F1 — Types: Update `AudioVersionRead` TS type
- [ ] Add `waveform_peaks: number[] | null` and `waveform_peaks_mini: number[] | null`
to the TypeScript version type (wherever API types live)
#### F2 — `audioService`: Accept and use pre-computed peaks
- [ ] Write unit tests for `AudioService.initialize()`:
- With peaks: calls `ws.load(url, [Float32Array])` → waveform renders immediately
- Without peaks: calls `ws.load(url)` → falls back to audio decode
- Same URL + same peaks → no re-initialization
- [ ] Update `AudioService.initialize(container, url, peaks?: number[])` in
`web/src/services/audioService.ts`:
- Call `ws.load(url, peaks ? [new Float32Array(peaks)] : undefined)`
#### F3 — `useWaveform` hook: Thread peaks through
- [ ] Write hook test: when `peaks` option is provided, it is forwarded to
`audioService.initialize`
- [ ] Add `peaks?: number[] | null` to `UseWaveformOptions` in
`web/src/hooks/useWaveform.ts`
- [ ] Forward `options.peaks` to `audioService.initialize()` in the effect
#### F4 — `PlayerPanel`: Pass peaks to hook
- [ ] Write component test: `PlayerPanel` passes `version.waveform_peaks` to
`useWaveform`
- [ ] Update `web/src/components/PlayerPanel.tsx` to extract and forward
`waveform_peaks`
#### F5 — `MiniWaveform`: New SVG component for library overview
- [ ] Write unit tests:
- Renders SVG with correct number of bars matching peaks length
- Null/empty peaks renders a grey placeholder (no crash)
- Accepts `peaks`, `width`, `height`, `color` props
- [ ] Create `web/src/components/MiniWaveform.tsx` — pure SVG, no WaveSurfer
- [ ] Integrate into song list / library view using `waveform_peaks_mini`
---
## Testing Strategy
| Layer | Tool |
|------------------|----------------------------------------------|
| Backend unit | pytest, synthetic numpy arrays |
| Backend integration | Real Postgres via docker-compose test profile |
| Frontend unit | Vitest + Testing Library |
| E2E | Playwright — assert waveform visible before audio `canplay` fires |
---
## Implementation Order
1. B1 — migration + model
2. B2 — worker (TDD: unit tests → implementation → integration test)
3. B3 — schema
4. B4 — router
5. B5 — verify versions list
6. F1 — TS types
7. F2 — audioService
8. F3 — useWaveform
9. F4 — PlayerPanel
10. F5 — MiniWaveform

View File

@@ -48,6 +48,8 @@ Files are **never copied** to RehearsalHub servers. The platform reads recording
## Quick start ## Quick start
> **Docker Registry Setup**: For production deployments using Gitea registry, see [DOCKER_REGISTRY.md](DOCKER_REGISTRY.md)
### 1. Configure environment ### 1. Configure environment
```bash ```bash

View File

@@ -251,3 +251,19 @@ tasks:
interactive: true interactive: true
cmds: cmds:
- "{{.COMPOSE}} exec redis redis-cli" - "{{.COMPOSE}} exec redis redis-cli"
# ── Registry ──────────────────────────────────────────────────────────────────
registry:login:
desc: Login to Gitea Docker registry
cmds:
- docker login git.sschuhmann.de
registry:build:
desc: Build all images with version tag (requires git tag)
cmds:
- bash scripts/build-and-push.sh
registry:push:
desc: Build and push all images to Gitea registry
deps: [registry:login, registry:build]

View File

@@ -0,0 +1,35 @@
"""Store waveform peaks inline in audio_versions table.
Replaces file-based waveform_url approach with two JSONB columns:
- waveform_peaks: 500-point peaks for the player (passed directly to WaveSurfer)
- waveform_peaks_mini: 100-point peaks for library/overview mini-waveform SVG
Revision ID: 0006_waveform_peaks_in_db
Revises: 0005_comment_tag
Create Date: 2026-04-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0006_waveform_peaks_in_db"
down_revision = "0005_comment_tag"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"audio_versions",
sa.Column("waveform_peaks", JSONB, nullable=True),
)
op.add_column(
"audio_versions",
sa.Column("waveform_peaks_mini", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("audio_versions", "waveform_peaks_mini")
op.drop_column("audio_versions", "waveform_peaks")

View File

@@ -232,6 +232,8 @@ class AudioVersion(Base):
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255)) nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text) cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text) waveform_url: Mapped[Optional[str]] = mapped_column(Text)
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer) duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10)) format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)

View File

@@ -10,11 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandMember, Member from rehearsalhub.db.models import AudioVersion, BandMember, Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
@@ -148,3 +149,37 @@ async def nc_upload(
) )
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title) log.info("nc-upload: registered version %s for song '%s'", version.id, song.title)
return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)} return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}
@router.post("/reindex-peaks", status_code=200)
async def reindex_peaks(
session: AsyncSession = Depends(get_session),
_: None = Depends(_verify_internal_secret),
):
"""Enqueue extract_peaks jobs for every audio_version that has no waveform_peaks yet.
Safe to call multiple times — only versions with null peaks are targeted.
Useful after:
- Fresh DB creation + directory scan (peaks not yet computed)
- Peak algorithm changes (clear waveform_peaks, then call this)
- Worker was down during initial transcode
"""
result = await session.execute(
select(AudioVersion).where(AudioVersion.waveform_peaks.is_(None)) # type: ignore[attr-defined]
)
versions = result.scalars().all()
if not versions:
return {"status": "ok", "queued": 0, "message": "All versions already have peaks"}
queue = RedisJobQueue(session)
queued = 0
for version in versions:
await queue.enqueue(
"extract_peaks",
{"version_id": str(version.id), "nc_file_path": version.nc_file_path},
)
queued += 1
log.info("reindex-peaks: queued %d extract_peaks jobs", queued)
return {"status": "ok", "queued": queued}

View File

@@ -180,49 +180,27 @@ async def create_version(
@router.get("/versions/{version_id}/waveform") @router.get("/versions/{version_id}/waveform")
async def get_waveform( async def get_waveform(
version_id: uuid.UUID, version_id: uuid.UUID,
resolution: str = Query("full", pattern="^(full|mini)$"),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
) -> Any: ) -> Any:
"""Return pre-computed waveform peaks from the database.
- `resolution=full` (default): 500-point peaks for the WaveSurfer player
- `resolution=mini`: 100-point peaks for the library overview thumbnail
"""
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member) version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.waveform_url:
if resolution == "mini":
peaks = version.waveform_peaks_mini
if peaks is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mini waveform not ready")
else:
peaks = version.waveform_peaks
if peaks is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
# Use the uploader's NC credentials — invited members may not have NC configured return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
uploader: Member | None = None
if version.uploaded_by:
uploader = await MemberRepository(session).get_by_id(version.uploaded_by)
storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, version.waveform_url)
except httpx.ConnectError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage service unavailable."
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Waveform file not found in storage."
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Storage returned an error."
)
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch waveform."
)
import json
return json.loads(data)
@router.get("/versions/{version_id}/stream") @router.get("/versions/{version_id}/stream")

View File

@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
nc_file_etag: str | None = None nc_file_etag: str | None = None
cdn_hls_base: str | None = None cdn_hls_base: str | None = None
waveform_url: str | None = None waveform_url: str | None = None
waveform_peaks: list[float] | None = None
waveform_peaks_mini: list[float] | None = None
duration_ms: int | None = None duration_ms: int | None = None
format: str | None = None format: str | None = None
file_size_bytes: int | None = None file_size_bytes: int | None = None

View File

@@ -0,0 +1,49 @@
"""Integration tests for waveform peaks stored inline in audio_versions."""
import pytest
from tests.factories import create_audio_version, create_band, create_member, create_song
@pytest.mark.asyncio
@pytest.mark.integration
async def test_audio_version_stores_waveform_peaks(db_session, current_member):
"""AudioVersion can store waveform_peaks and waveform_peaks_mini JSONB data."""
from rehearsalhub.repositories.audio_version import AudioVersionRepository
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
peaks_500 = [float(i) / 500 for i in range(500)]
peaks_100 = [float(i) / 100 for i in range(100)]
repo = AudioVersionRepository(db_session)
updated = await repo.update(
version,
waveform_peaks=peaks_500,
waveform_peaks_mini=peaks_100,
)
await db_session.commit()
fetched = await repo.get_by_id(updated.id)
assert fetched is not None
assert fetched.waveform_peaks is not None
assert len(fetched.waveform_peaks) == 500
assert fetched.waveform_peaks_mini is not None
assert len(fetched.waveform_peaks_mini) == 100
assert fetched.waveform_peaks[0] == pytest.approx(0.0)
assert fetched.waveform_peaks[1] == pytest.approx(1 / 500)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_audio_version_peaks_default_null(db_session, current_member):
"""waveform_peaks and waveform_peaks_mini are null by default."""
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
await db_session.commit()
assert version.waveform_peaks is None
assert version.waveform_peaks_mini is None

View File

@@ -0,0 +1,64 @@
"""Unit tests for AudioVersionRead schema — waveform peaks serialization."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from rehearsalhub.db.models import AudioVersion
from rehearsalhub.schemas.audio_version import AudioVersionRead
def _make_version(peaks=None, peaks_mini=None) -> MagicMock:
"""Build a mock AudioVersion ORM object."""
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.version_number = 1
v.label = None
v.nc_file_path = "/bands/test/v1.wav"
v.nc_file_etag = "abc123"
v.cdn_hls_base = None
v.waveform_url = None
v.waveform_peaks = peaks
v.waveform_peaks_mini = peaks_mini
v.duration_ms = 5000
v.format = "wav"
v.file_size_bytes = 1024
v.analysis_status = "done"
v.uploaded_by = None
v.uploaded_at = datetime.now(timezone.utc)
return v
def test_audio_version_read_includes_waveform_peaks():
peaks = [float(i) / 500 for i in range(500)]
peaks_mini = [float(i) / 100 for i in range(100)]
v = _make_version(peaks=peaks, peaks_mini=peaks_mini)
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks is not None
assert len(schema.waveform_peaks) == 500
assert schema.waveform_peaks_mini is not None
assert len(schema.waveform_peaks_mini) == 100
def test_audio_version_read_peaks_default_null():
v = _make_version(peaks=None, peaks_mini=None)
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks is None
assert schema.waveform_peaks_mini is None
def test_audio_version_read_peaks_values_preserved():
peaks = [0.0, 0.5, 1.0]
v = _make_version(peaks=peaks, peaks_mini=[0.25, 0.75])
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks == [0.0, 0.5, 1.0]
assert schema.waveform_peaks_mini == [0.25, 0.75]

View File

@@ -0,0 +1,38 @@
"""Confirm that list_versions returns waveform_peaks inline (no extra request needed)."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
from rehearsalhub.db.models import AudioVersion
from rehearsalhub.schemas.audio_version import AudioVersionRead
def test_audio_version_read_includes_peaks_in_list_serialization():
"""AudioVersionRead (used by list_versions) serializes waveform_peaks inline."""
peaks = [0.1, 0.5, 0.9]
mini = [0.3, 0.7]
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.version_number = 1
v.label = None
v.nc_file_path = "/test/v1.wav"
v.nc_file_etag = "etag"
v.cdn_hls_base = None
v.waveform_url = None
v.waveform_peaks = peaks
v.waveform_peaks_mini = mini
v.duration_ms = 3000
v.format = "wav"
v.file_size_bytes = 512
v.analysis_status = "done"
v.uploaded_by = None
v.uploaded_at = datetime.now(timezone.utc)
schema = AudioVersionRead.model_validate(v)
serialized = schema.model_dump()
assert serialized["waveform_peaks"] == peaks
assert serialized["waveform_peaks_mini"] == mini

View File

@@ -0,0 +1,120 @@
"""Unit tests for GET /versions/{id}/waveform endpoint — reads peaks from DB."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from rehearsalhub.db.models import AudioVersion, Member, Song
def _make_member() -> MagicMock:
m = MagicMock(spec=Member)
m.id = uuid.uuid4()
m.nc_url = "http://nc.test"
m.nc_username = "user"
m.nc_password = "pass"
return m
def _make_version(peaks=None, peaks_mini=None, has_waveform_url=False) -> MagicMock:
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.uploaded_by = None
v.waveform_url = "waveforms/test.json" if has_waveform_url else None
v.waveform_peaks = peaks
v.waveform_peaks_mini = peaks_mini
v.cdn_hls_base = None
v.nc_file_path = "/bands/test/v1.wav"
return v
def _make_song(band_id: uuid.UUID) -> MagicMock:
s = MagicMock(spec=Song)
s.id = uuid.uuid4()
s.band_id = band_id
return s
@pytest.mark.asyncio
async def test_waveform_returns_full_peaks_from_db(mock_session):
"""GET /versions/{id}/waveform returns 500-point peaks from DB column."""
from rehearsalhub.routers.versions import get_waveform
peaks = [float(i) / 500 for i in range(500)]
version = _make_version(peaks=peaks)
member = _make_member()
band_id = uuid.uuid4()
song = _make_song(band_id)
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
result = await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert result["data"] == peaks
assert result["length"] == 500
assert "mini" not in result
@pytest.mark.asyncio
async def test_waveform_returns_mini_peaks_with_resolution_param(mock_session):
"""GET /versions/{id}/waveform?resolution=mini returns 100-point peaks."""
from rehearsalhub.routers.versions import get_waveform
peaks_mini = [float(i) / 100 for i in range(100)]
version = _make_version(peaks=[0.5] * 500, peaks_mini=peaks_mini)
member = _make_member()
band_id = uuid.uuid4()
song = _make_song(band_id)
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
result = await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert result["data"] == peaks_mini
assert result["length"] == 100
@pytest.mark.asyncio
async def test_waveform_404_when_no_peaks_in_db(mock_session):
"""GET /versions/{id}/waveform returns 404 when no peaks stored yet."""
from fastapi import HTTPException
from rehearsalhub.routers.versions import get_waveform
version = _make_version(peaks=None, peaks_mini=None)
member = _make_member()
song = _make_song(uuid.uuid4())
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
with pytest.raises(HTTPException) as exc_info:
await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_waveform_mini_404_when_no_mini_peaks(mock_session):
"""GET /versions/{id}/waveform?resolution=mini returns 404 when no mini peaks stored."""
from fastapi import HTTPException
from rehearsalhub.routers.versions import get_waveform
version = _make_version(peaks=[0.5] * 500, peaks_mini=None)
member = _make_member()
song = _make_song(uuid.uuid4())
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
with pytest.raises(HTTPException) as exc_info:
await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert exc_info.value.status_code == 404

View File

@@ -25,6 +25,7 @@ services:
build: build:
context: ./api context: ./api
target: development target: development
command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub} 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_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}

41
scripts/build-and-push.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
# Configuration
REGISTRY="git.sschuhmann.de/sschuhmann/rehearshalhub"
COMPONENTS=("api" "web" "worker" "watcher")
# Get version from git tag
get_version() {
local tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -z "$tag" ]]; then
echo "Error: No git tags found. Please create a tag first (e.g., git tag v1.0.0)" >&2
exit 1
fi
# Remove v prefix if present for semantic versioning
echo "${tag#v}"
}
# Main build and push function
build_and_push() {
local version=$1
echo "Building and pushing version: $version"
for component in "${COMPONENTS[@]}"; do
echo "Building $component..."
docker build -t "$REGISTRY/$component-$version" -f "$component/Dockerfile" --target production "$component"
echo "Pushing $component-$version..."
docker push "$REGISTRY/$component-$version"
# Also tag as latest for convenience
docker tag "$REGISTRY/$component-$version" "$REGISTRY/$component-latest"
docker push "$REGISTRY/$component-latest"
done
echo "All components built and pushed successfully!"
}
# Execute
VERSION=$(get_version)
build_and_push "$VERSION"

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "→ Checking for Nextcloud service..."
# Check if nextcloud service exists
if ! docker compose ps | grep -q nextcloud; then
echo " Nextcloud service not found in compose setup"
echo " Skipping Nextcloud configuration (external setup required)"
exit 0
fi
echo "→ Configuring Nextcloud via occ..."
NC="docker compose exec -T nextcloud php occ"
# Enable recommended apps
$NC app:enable notify_push 2>/dev/null || echo " notify_push not available, skipping"
$NC app:enable files_accesscontrol 2>/dev/null || echo " files_accesscontrol not available, skipping"
# Create service account for rehearsalhub
$NC user:add \
--display-name "RehearsalHub Service" \
--password-from-env \
rh_service \
<<< "${NEXTCLOUD_ADMIN_PASSWORD:-change_me}" || echo " Service account may already exist"
# Set permissions
$NC user:setting rh_service core lang en
$NC config:system:set trusted_domains 1 --value="${DOMAIN:-localhost}"
$NC config:system:set trusted_domains 2 --value="nc.${DOMAIN:-localhost}"
# Create base folder structure
$NC files:scan --all
echo "✓ Nextcloud setup complete"

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
API="${API_URL:-http://localhost/api/v1}"
echo "→ Running database migrations..."
docker compose exec api alembic upgrade head
echo "→ Seeding admin user..."
REGISTER_RESP=$(curl -sf -X POST "$API/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@rehearsalhub.local",
"password": "changeme123!",
"display_name": "Admin"
}') || echo " Admin user may already exist"
echo "→ Logging in to get token..."
TOKEN_RESP=$(curl -sf -X POST "$API/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "admin@rehearsalhub.local", "password": "changeme123!"}')
TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "→ Creating demo band..."
curl -sf -X POST "$API/bands" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "Demo Band",
"slug": "demo-band",
"genre_tags": ["rock", "jam"]
}' | python3 -m json.tool
echo ""
echo "✓ Seed complete!"
echo " Admin: admin@rehearsalhub.local / changeme123!"
echo " API docs: https://${DOMAIN:-localhost}/api/docs"

View File

@@ -1,16 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import { BrowserRouter, Route, Routes, Navigate, useParams, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import "./index.css"; import "./index.css";
import { isLoggedIn } from "./api/client"; import { isLoggedIn } from "./api/client";
import { AppShell } from "./components/AppShell"; import { AppShell } from "./components/AppShell";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage"; import { BandPage } from "./pages/BandPage";
import { BandSettingsPage } from "./pages/BandSettingsPage";
import { SessionPage } from "./pages/SessionPage"; import { SessionPage } from "./pages/SessionPage";
import { SongPage } from "./pages/SongPage"; import { SongPage } from "./pages/SongPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { InvitePage } from "./pages/InvitePage"; import { InvitePage } from "./pages/InvitePage";
import { useBandStore } from "./stores/bandStore";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
@@ -28,6 +29,20 @@ function ShellRoute({ children }: { children: React.ReactNode }) {
); );
} }
// Redirect /bands/:bandId/settings/:panel → /settings?section=:panel, setting bandStore
function BandSettingsRedirect() {
const { bandId, panel } = useParams<{ bandId: string; panel?: string }>();
const navigate = useNavigate();
const { setActiveBandId } = useBandStore();
useEffect(() => {
if (bandId) setActiveBandId(bandId);
navigate(`/settings${panel ? `?section=${panel}` : ""}`, { replace: true });
}, [bandId, panel, navigate, setActiveBandId]);
return null;
}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -51,18 +66,8 @@ export default function App() {
</ShellRoute> </ShellRoute>
} }
/> />
<Route <Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
path="/bands/:bandId/settings" <Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
element={<Navigate to="members" replace />}
/>
<Route
path="/bands/:bandId/settings/:panel"
element={
<ShellRoute>
<BandSettingsPage />
</ShellRoute>
}
/>
<Route <Route
path="/bands/:bandId/sessions/:sessionId" path="/bands/:bandId/sessions/:sessionId"
element={ element={

View File

@@ -0,0 +1,371 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SessionSummary {
id: string;
date: string;
label: string | null;
recording_count: number;
}
interface SongSummary {
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SessionDetail {
id: string;
band_id: string;
date: string;
label: string | null;
notes: string | null;
recording_count: number;
songs: SongSummary[];
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatSessionDate(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";
if (diffDays === 7) return "Last week";
return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short", year: "numeric" });
}
function computeWaveBars(seed: string): number[] {
let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337);
return Array.from({ length: 12 }, () => {
s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0;
return Math.max(15, Math.floor((s / 0xffffffff) * 100));
});
}
function MiniWave({ songId, active }: { songId: string; active: boolean }) {
const bars = useMemo(() => computeWaveBars(songId), [songId]);
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 26, flexShrink: 0 }}>
{bars.map((h, i) => (
<div
key={i}
style={{
width: 2,
height: `${h}%`,
borderRadius: 1,
background: active
? `rgba(20,184,166,${0.3 + (h / 100) * 0.5})`
: "rgba(255,255,255,0.1)",
transition: "background 0.15s",
}}
/>
))}
</div>
);
}
// ── Tag badge ─────────────────────────────────────────────────────────────────
const TAG_COLORS: Record<string, { bg: string; color: string }> = {
jam: { bg: "rgba(20,184,166,0.12)", color: "#2dd4bf" },
riff: { bg: "rgba(34,211,238,0.1)", color: "#67e8f9" },
idea: { bg: "rgba(52,211,153,0.1)", color: "#6ee7b7" },
};
function TagBadge({ tag }: { tag: string }) {
const style = TAG_COLORS[tag.toLowerCase()] ?? { bg: "rgba(255,255,255,0.06)", color: "rgba(232,233,240,0.45)" };
return (
<span style={{ fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 4, letterSpacing: "0.02em", background: style.bg, color: style.color, flexShrink: 0 }}>
{tag}
</span>
);
}
// ── Track row ─────────────────────────────────────────────────────────────────
function TrackRow({
song,
index,
active,
onSelect,
}: {
song: SongSummary;
index: number;
active: boolean;
onSelect: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 20px",
cursor: "pointer",
position: "relative",
background: active ? "rgba(20,184,166,0.06)" : hovered ? "rgba(255,255,255,0.025)" : "transparent",
transition: "background 0.12s",
}}
>
{active && (
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 2, background: "linear-gradient(to bottom, #0d9488, #22d3ee)" }} />
)}
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.2)", width: 20, textAlign: "right", flexShrink: 0, fontFamily: "monospace" }}>
{String(index + 1).padStart(2, "0")}
</span>
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, color: active ? "#2dd4bf" : "#e8e9f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", transition: "color 0.12s" }}>
{song.title}
</span>
{song.tags[0] && <TagBadge tag={song.tags[0]} />}
<MiniWave songId={song.id} active={active} />
</div>
);
}
// ── Session group ─────────────────────────────────────────────────────────────
function SessionGroup({
bandId,
session,
selectedSongId,
search,
filterTag,
onSelectSong,
defaultOpen,
}: {
bandId: string;
session: SessionSummary;
selectedSongId: string | null;
search: string;
filterTag: string;
onSelectSong: (songId: string) => void;
defaultOpen: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const { data: detail } = useQuery({
queryKey: ["session", session.id],
queryFn: () => api.get<SessionDetail>(`/bands/${bandId}/sessions/${session.id}`),
enabled: isOpen,
});
const filteredSongs = useMemo(() => {
if (!detail?.songs) return [];
return detail.songs.filter((song) => {
const matchesSearch = !search || song.title.toLowerCase().includes(search.toLowerCase());
const matchesTag = !filterTag || song.tags.some((t) => t.toLowerCase() === filterTag);
return matchesSearch && matchesTag;
});
}, [detail, search, filterTag]);
return (
<div>
{/* Session header */}
<div
onClick={() => setIsOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 20px 5px",
cursor: "pointer",
}}
>
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase",
background: "linear-gradient(135deg, #0d9488, #22d3ee)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>
{formatSessionDate(session.date)}
</span>
{session.label && (
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.35)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session.label}
</span>
)}
{!session.label && <span style={{ flex: 1 }} />}
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.28)", background: "rgba(255,255,255,0.04)", padding: "2px 8px", borderRadius: 20, flexShrink: 0 }}>
{session.recording_count}
</span>
<svg
width="12" height="12" viewBox="0 0 12 12" fill="none"
style={{ color: "rgba(232,233,240,0.28)", transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.18s", flexShrink: 0 }}
>
<path d="M4 2l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Track list */}
{isOpen && (
<div>
{!detail && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
Loading
</div>
)}
{detail && (filteredSongs.length > 0 ? filteredSongs : detail.songs).map((song, i) => (
<TrackRow
key={song.id}
song={song}
index={i}
active={song.id === selectedSongId}
onSelect={() => onSelectSong(song.id)}
/>
))}
{detail && detail.songs.length === 0 && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
No recordings yet.
</div>
)}
{detail && search && filteredSongs.length === 0 && detail.songs.length > 0 && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
No matches in this session.
</div>
)}
</div>
)}
</div>
);
}
// ── Filter chips ──────────────────────────────────────────────────────────────
const FILTER_CHIPS = [
{ label: "All", value: "" },
{ label: "Jam", value: "jam" },
{ label: "Riff", value: "riff" },
{ label: "Idea", value: "idea" },
];
// ── LibraryPanel ──────────────────────────────────────────────────────────────
interface LibraryPanelProps {
bandId: string;
selectedSongId: string | null;
onSelectSong: (songId: string) => void;
}
export function LibraryPanel({ bandId, selectedSongId, onSelectSong }: LibraryPanelProps) {
const [search, setSearch] = useState("");
const [filterTag, setFilterTag] = useState("");
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId,
});
const border = "rgba(255,255,255,0.06)";
return (
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
background: "#0c1612",
height: "100%",
overflow: "hidden",
}}>
{/* Header */}
<div style={{ padding: "22px 20px 12px", flexShrink: 0 }}>
<h2 style={{ margin: "0 0 14px", fontSize: 22, fontWeight: 800, letterSpacing: -0.5, background: "linear-gradient(135deg, #e8e9f0 30%, rgba(232,233,240,0.5))", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>
Library
</h2>
{/* Search */}
<div style={{ display: "flex", alignItems: "center", gap: 8, background: "#101c18", border: `1px solid ${border}`, borderRadius: 8, padding: "8px 12px", transition: "border-color 0.15s" }}
onFocusCapture={(e) => (e.currentTarget.style.borderColor = "rgba(20,184,166,0.4)")}
onBlurCapture={(e) => (e.currentTarget.style.borderColor = border)}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "rgba(232,233,240,0.25)", flexShrink: 0 }}>
<circle cx="6" cy="6" r="4.5" />
<path d="M10 10l2.5 2.5" strokeLinecap="round" />
</svg>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search recordings…"
style={{ background: "none", border: "none", outline: "none", fontFamily: "inherit", fontSize: 13, color: "#e8e9f0", flex: 1, caretColor: "#2dd4bf" }}
/>
{search && (
<button onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.3)", padding: 0, display: "flex", lineHeight: 1 }}>
</button>
)}
</div>
</div>
{/* Filter chips */}
<div style={{ display: "flex", gap: 5, padding: "0 20px 10px", borderBottom: `1px solid ${border}`, flexShrink: 0, overflowX: "auto" }}>
{FILTER_CHIPS.map((chip) => {
const on = filterTag === chip.value;
return (
<button
key={chip.value}
onClick={() => setFilterTag(chip.value)}
style={{
fontSize: 11, fontWeight: 600, padding: "4px 12px", borderRadius: 20, cursor: "pointer", whiteSpace: "nowrap",
border: on ? "1px solid rgba(20,184,166,0.4)" : `1px solid ${border}`,
background: on ? "rgba(20,184,166,0.1)" : "transparent",
color: on ? "#2dd4bf" : "rgba(232,233,240,0.35)",
fontFamily: "inherit",
transition: "all 0.12s",
}}
>
{chip.label}
</button>
);
})}
</div>
{/* Session list */}
<div style={{ flex: 1, overflowY: "auto" }}>
<style>{`
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
`}</style>
{!sessions && (
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>Loading sessions</div>
)}
{sessions?.length === 0 && (
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>
No sessions yet. Go to Storage settings to scan your Nextcloud folder.
</div>
)}
{sessions?.map((session, i) => (
<SessionGroup
key={session.id}
bandId={bandId}
session={session}
selectedSongId={selectedSongId}
search={search}
filterTag={filterTag}
onSelectSong={onSelectSong}
defaultOpen={i === 0}
/>
))}
{/* Bottom padding for last item breathing room */}
<div style={{ height: 20 }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { MiniWaveform } from "./MiniWaveform";
describe("MiniWaveform", () => {
it("renders an SVG element", () => {
const peaks = Array.from({ length: 100 }, (_, i) => i / 100);
const { container } = render(<MiniWaveform peaks={peaks} width={120} height={32} />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
});
it("renders the correct number of bars matching peaks length", () => {
const peaks = Array.from({ length: 100 }, (_, i) => i / 100);
const { container } = render(<MiniWaveform peaks={peaks} width={120} height={32} />);
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(100);
});
it("renders a placeholder when peaks is null", () => {
const { container } = render(<MiniWaveform peaks={null} width={120} height={32} />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
// With null peaks, no bars — just the placeholder rect
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(1); // single placeholder bar
});
it("renders a placeholder when peaks is empty array", () => {
const { container } = render(<MiniWaveform peaks={[]} width={120} height={32} />);
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(1);
});
it("applies correct SVG dimensions", () => {
const peaks = [0.5, 0.5];
const { container } = render(<MiniWaveform peaks={peaks} width={200} height={48} />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("200");
expect(svg?.getAttribute("height")).toBe("48");
});
it("uses the provided color for bars", () => {
const peaks = [0.5, 0.5];
const { container } = render(
<MiniWaveform peaks={peaks} width={100} height={32} color="#ff0000" />
);
const rect = container.querySelector("rect");
expect(rect?.getAttribute("fill")).toBe("#ff0000");
});
});

View File

@@ -0,0 +1,62 @@
/**
* MiniWaveform — pure SVG component for rendering waveform_peaks_mini.
*
* Renders pre-computed 100-point peaks as vertical bars. No WaveSurfer dependency —
* lightweight enough to use in library/song list views for every song card.
*
* Props:
* peaks — array of 0-1 normalized peak values (ideally 100 points), or null
* width — SVG width in px
* height — SVG height in px
* color — bar fill color (default: teal accent)
*/
interface MiniWaveformProps {
peaks: number[] | null;
width: number;
height: number;
color?: string;
}
export function MiniWaveform({
peaks,
width,
height,
color = "#14b8a6",
}: MiniWaveformProps) {
const isEmpty = !peaks || peaks.length === 0;
if (isEmpty) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.06)" rx={2} />
</svg>
);
}
const barCount = peaks.length;
const gap = 1;
const barWidth = Math.max(1, (width - gap * (barCount - 1)) / barCount);
const midY = height / 2;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
{peaks.map((peak, i) => {
const barHeight = Math.max(1, peak * height);
const x = i * (barWidth + gap);
const y = midY - barHeight / 2;
return (
<rect
key={i}
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={color}
rx={0.5}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,485 @@
import { useRef, useState, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import type { MemberRead } from "../api/auth";
import { useWaveform } from "../hooks/useWaveform";
import { MiniWaveform } from "./MiniWaveform";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SongRead {
id: string;
band_id: string;
session_id: string | null;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SongVersion {
id: string;
version_number: number;
label: string | null;
analysis_status: string;
waveform_peaks: number[] | null;
waveform_peaks_mini: number[] | null;
}
interface SongComment {
id: string;
song_id: string;
body: string;
author_id: string;
author_name: string;
author_avatar_url: string | null;
timestamp: number | null;
tag: string | null;
created_at: string;
}
interface SessionInfo {
id: string;
band_id: string;
date: string;
label: string | null;
songs: { id: string; title: string; status: string; tags: string[] }[];
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function getInitials(name: string): string {
return name.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2);
}
const MEMBER_COLORS = [
{ bg: "rgba(91,156,240,0.18)", border: "rgba(91,156,240,0.6)", text: "#7aabf0" },
{ bg: "rgba(200,90,180,0.18)", border: "rgba(200,90,180,0.6)", text: "#d070c0" },
{ bg: "rgba(52,211,153,0.18)", border: "rgba(52,211,153,0.6)", text: "#34d399" },
{ bg: "rgba(20,184,166,0.18)", border: "rgba(20,184,166,0.6)", text: "#2dd4bf" },
{ bg: "rgba(34,211,238,0.18)", border: "rgba(34,211,238,0.6)", text: "#22d3ee" },
];
function memberColor(authorId: string) {
let h = 0;
for (let i = 0; i < authorId.length; i++) h = (h * 31 + authorId.charCodeAt(i)) >>> 0;
return MEMBER_COLORS[h % MEMBER_COLORS.length];
}
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
suggestion: { bg: "rgba(91,156,240,0.1)", color: "#7aabf0" },
issue: { bg: "rgba(244,63,94,0.1)", color: "#f87171" },
keeper: { bg: "rgba(52,211,153,0.1)", color: "#34d399" },
};
// ── Sub-components ────────────────────────────────────────────────────────────
function Avatar({ name, avatarUrl, authorId, size = 24 }: { name: string; avatarUrl: string | null; authorId: string; size?: number }) {
const mc = memberColor(authorId);
if (avatarUrl) return <img src={avatarUrl} alt={name} style={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />;
return (
<div style={{ width: size, height: size, borderRadius: "50%", background: mc.bg, border: `1.5px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.38, fontWeight: 700, flexShrink: 0 }}>
{getInitials(name)}
</div>
);
}
function TransportBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) {
const [hovered, setHovered] = useState(false);
return (
<button onClick={onClick} title={title} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{ width: 36, height: 36, borderRadius: "50%", background: hovered ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", color: hovered ? "rgba(232,233,240,0.72)" : "rgba(232,233,240,0.35)", flexShrink: 0, transition: "all 0.12s" }}>
{children}
</button>
);
}
function WaveformPins({
comments, duration, containerWidth, onSeek, onScrollToComment,
}: {
comments: SongComment[]; duration: number; containerWidth: number;
onSeek: (t: number) => void; onScrollToComment: (id: string) => void;
}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pinned = comments.filter((c) => c.timestamp != null);
return (
<div style={{ position: "relative", height: 44, overflow: "visible" }}>
{pinned.map((c) => {
const pct = duration > 0 ? c.timestamp! / duration : 0;
const left = Math.round(pct * containerWidth);
const mc = memberColor(c.author_id);
const isHovered = hoveredId === c.id;
return (
<div key={c.id}
style={{ position: "absolute", left, top: 0, transform: "translateX(-50%)", display: "flex", flexDirection: "column", alignItems: "center", cursor: "pointer", zIndex: 10, transition: "transform 0.12s", ...(isHovered ? { transform: "translateX(-50%) scale(1.15)" } : {}) }}
onMouseEnter={() => setHoveredId(c.id)} onMouseLeave={() => setHoveredId(null)}
onClick={() => { onSeek(c.timestamp!); onScrollToComment(c.id); }}
>
{isHovered && (
<div style={{ position: "absolute", bottom: "calc(100% + 6px)", left: "50%", transform: "translateX(-50%)", background: "#142420", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, padding: "8px 10px", width: 180, zIndex: 50, pointerEvents: "none" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: mc.bg, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700 }}>
{getInitials(c.author_name)}
</div>
<span style={{ fontSize: 11, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#2dd4bf", marginLeft: "auto" }}>{formatTime(c.timestamp!)}</span>
</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.42)", lineHeight: 1.4 }}>
{c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body}
</div>
</div>
)}
<div style={{ width: 22, height: 22, borderRadius: "50%", background: mc.bg, border: `2px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700, boxShadow: "0 2px 8px rgba(0,0,0,0.45)" }}>
{getInitials(c.author_name)}
</div>
<div style={{ width: 1.5, height: 14, background: mc.text, opacity: 0.3, marginTop: 2 }} />
</div>
);
})}
</div>
);
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function IconSkipBack() {
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor"><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
}
function IconSkipFwd() {
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" style={{ transform: "scaleX(-1)" }}><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
}
function IconPlay() {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2l10 6-10 6V2z" /></svg>;
}
function IconPause() {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2h3v12H4zm6 0h3v12H10z" /></svg>;
}
// ── PlayerPanel ───────────────────────────────────────────────────────────────
interface PlayerPanelProps {
songId: string;
bandId: string;
/** Called when back button is clicked. If omitted, navigates to /bands/:bandId */
onBack?: () => void;
}
export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
const navigate = useNavigate();
const qc = useQueryClient();
const waveformRef = useRef<HTMLDivElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [commentBody, setCommentBody] = useState("");
const [selectedTag, setSelectedTag] = useState("");
const [composeFocused, setComposeFocused] = useState(false);
const [waveformWidth, setWaveformWidth] = useState(0);
// State resets automatically because BandPage passes key={selectedSongId} to PlayerPanel
// ── Queries ──────────────────────────────────────────────────────────────
const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get<MemberRead>("/auth/me") });
const { data: song } = useQuery({
queryKey: ["song", songId],
queryFn: () => api.get<SongRead>(`/songs/${songId}`),
enabled: !!songId,
});
const { data: versions } = useQuery({
queryKey: ["versions", songId],
queryFn: () => api.get<SongVersion[]>(`/songs/${songId}/versions`),
enabled: !!songId,
});
const { data: session } = useQuery({
queryKey: ["session", song?.session_id],
queryFn: () => api.get<SessionInfo>(`/bands/${bandId}/sessions/${song!.session_id}`),
enabled: !!song?.session_id && !!bandId,
});
const { data: comments } = useQuery<SongComment[]>({
queryKey: ["comments", songId],
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
enabled: !!songId,
});
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
const activeVersionData = versions?.find((v) => v.id === activeVersion) ?? versions?.[0] ?? null;
// ── Waveform ──────────────────────────────────────────────────────────────
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
peaksUrl: null,
peaks: activeVersionData?.waveform_peaks ?? null,
songId,
bandId,
});
useEffect(() => {
const el = waveformContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWaveformWidth(entries[0].contentRect.width));
ro.observe(el);
setWaveformWidth(el.offsetWidth);
return () => ro.disconnect();
}, []);
// Space bar shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
if (e.code === "Space") { e.preventDefault(); if (isPlaying) pause(); else if (isReady) play(); }
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isPlaying, isReady, play, pause]);
// ── Mutations ─────────────────────────────────────────────────────────────
const addCommentMutation = useMutation({
mutationFn: ({ body, timestamp, tag }: { body: string; timestamp: number; tag: string }) =>
api.post(`/songs/${songId}/comments`, { body, timestamp, tag: tag || null }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["comments", songId] }); setCommentBody(""); setSelectedTag(""); },
});
const deleteCommentMutation = useMutation({
mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
});
const scrollToComment = useCallback((commentId: string) => {
document.getElementById(`comment-${commentId}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, []);
const handleBack = () => {
if (onBack) onBack();
else navigate(`/bands/${bandId}`);
};
const border = "rgba(255,255,255,0.06)";
// ── Render ────────────────────────────────────────────────────────────────
return (
<div style={{ display: "flex", flexDirection: "column", flex: 1, height: "100%", overflow: "hidden", background: "#080f0d", minWidth: 0 }}>
{/* Breadcrumb / header */}
<div style={{ padding: "11px 20px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
<button onClick={handleBack}
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
>
Library
</button>
{session && (
<>
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}></span>
<button onClick={() => navigate(`/bands/${bandId}/sessions/${session.id}`)}
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
>
{session.date}
</button>
</>
)}
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}></span>
<span style={{ fontSize: 12, color: "rgba(232,233,240,0.72)", fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
{song?.title ?? "…"}
</span>
{/* Version selector */}
{versions && versions.length > 1 && (
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{versions.map((v) => (
<button key={v.id} onClick={() => setSelectedVersionId(v.id)}
style={{ background: v.id === activeVersion ? "rgba(20,184,166,0.12)" : "transparent", border: `1px solid ${v.id === activeVersion ? "rgba(20,184,166,0.3)" : "rgba(255,255,255,0.09)"}`, borderRadius: 6, padding: "4px 10px", color: v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 11, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 6 }}>
{v.waveform_peaks_mini && (
<MiniWaveform
peaks={v.waveform_peaks_mini}
width={32}
height={16}
color={v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.25)"}
/>
)}
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
</button>
))}
</div>
)}
<button style={{ background: "transparent", border: `1px solid rgba(255,255,255,0.09)`, borderRadius: 6, color: "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 12, padding: "5px 12px", fontFamily: "inherit", flexShrink: 0 }}>
Share
</button>
</div>
{/* Body */}
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
{/* Waveform section */}
<div style={{ padding: "16px 20px", flexShrink: 0 }}>
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "14px 14px 10px", marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>{song?.title ?? "…"}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.28)" }}>{isReady ? formatTime(duration) : "—"}</span>
</div>
{/* Pin layer + canvas */}
<div ref={waveformContainerRef} style={{ position: "relative" }}>
{isReady && duration > 0 && comments && (
<WaveformPins comments={comments} duration={duration} containerWidth={waveformWidth} onSeek={seekTo} onScrollToComment={scrollToComment} />
)}
{!isReady && <div style={{ height: 44 }} />}
<div ref={waveformRef} />
{error && <div style={{ color: "#f87171", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Audio error: {error}</div>}
{!isReady && !error && <div style={{ color: "rgba(232,233,240,0.28)", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Loading audio</div>}
</div>
{/* Time bar */}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, padding: "0 1px" }}>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#2dd4bf" }}>{formatTime(currentTime)}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady && duration > 0 ? formatTime(duration / 2) : "—"}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady ? formatTime(duration) : "—"}</span>
</div>
</div>
{/* Transport */}
<div style={{ display: "flex", justifyContent: "center", gap: 10, padding: "4px 0 4px" }}>
<TransportBtn onClick={() => seekTo(Math.max(0, currentTime - 30))} title="30s"><IconSkipBack /></TransportBtn>
<button
onClick={isPlaying ? pause : play}
disabled={!activeVersion}
style={{ width: 46, height: 46, background: "linear-gradient(135deg, #0d9488, #06b6d4)", borderRadius: "50%", border: "none", display: "flex", alignItems: "center", justifyContent: "center", cursor: activeVersion ? "pointer" : "default", opacity: activeVersion ? 1 : 0.4, flexShrink: 0, transition: "transform 0.12s, box-shadow 0.12s", boxShadow: "0 4px 16px rgba(20,184,166,0.35)" }}
onMouseEnter={(e) => { if (activeVersion) { e.currentTarget.style.boxShadow = "0 6px 24px rgba(20,184,166,0.55)"; e.currentTarget.style.transform = "scale(1.04)"; } }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "0 4px 16px rgba(20,184,166,0.35)"; e.currentTarget.style.transform = "scale(1)"; }}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
<TransportBtn onClick={() => seekTo(currentTime + 30)} title="+30s"><IconSkipFwd /></TransportBtn>
</div>
</div>
{/* Comments section */}
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden", borderTop: `1px solid ${border}`, background: "rgba(0,0,0,0.1)" }}>
{/* Header */}
<div style={{ padding: "12px 15px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>Comments</span>
{comments && comments.length > 0 && (
<span style={{ fontSize: 11, background: "rgba(20,184,166,0.12)", color: "#2dd4bf", padding: "1px 8px", borderRadius: 10 }}>{comments.length}</span>
)}
</div>
{/* Compose */}
<div style={{ padding: "11px 14px", borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
<div style={{ display: "flex", gap: 9, alignItems: "flex-start" }}>
{me ? (
<Avatar name={me.display_name} avatarUrl={me.avatar_url ?? null} authorId={me.id} size={26} />
) : (
<div style={{ width: 26, height: 26, borderRadius: "50%", background: "rgba(255,255,255,0.06)", flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontFamily: "monospace", background: "rgba(20,184,166,0.1)", color: "#2dd4bf", border: "1px solid rgba(20,184,166,0.22)", padding: "3px 9px", borderRadius: 20, display: "flex", alignItems: "center", gap: 6 }}>
{isPlaying && <div style={{ width: 6, height: 6, borderRadius: "50%", background: "#2dd4bf", animation: "pp-blink 1.1s infinite" }} />}
{formatTime(currentTime)}
</div>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.2)" }}>· pins to playhead</span>
</div>
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
onFocus={() => setComposeFocused(true)}
onBlur={() => { if (!commentBody.trim()) setComposeFocused(false); }}
placeholder="What do you hear at this moment…"
style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: composeFocused ? "1px solid rgba(20,184,166,0.35)" : `1px solid rgba(255,255,255,0.07)`, borderRadius: 7, padding: "8px 10px", color: "#e8e9f0", fontSize: 12, resize: "none", outline: "none", fontFamily: "inherit", height: composeFocused ? 68 : 42, transition: "height 0.18s, border-color 0.15s", boxSizing: "border-box" }}
/>
{composeFocused && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 7 }}>
<div style={{ display: "flex", gap: 5 }}>
{(["suggestion", "issue", "keeper"] as const).map((tag) => (
<button key={tag} onClick={() => setSelectedTag((t) => (t === tag ? "" : tag))}
style={{ fontSize: 11, padding: "3px 8px", borderRadius: 4, cursor: "pointer", fontFamily: "inherit", background: selectedTag === tag ? TAG_STYLES[tag].bg : "rgba(255,255,255,0.05)", border: `1px solid ${selectedTag === tag ? TAG_STYLES[tag].color + "44" : "rgba(255,255,255,0.07)"}`, color: selectedTag === tag ? TAG_STYLES[tag].color : "rgba(232,233,240,0.32)", transition: "all 0.12s" }}>
{tag}
</button>
))}
</div>
<button
onClick={() => { if (commentBody.trim()) addCommentMutation.mutate({ body: commentBody.trim(), timestamp: currentTime, tag: selectedTag }); }}
disabled={!commentBody.trim() || addCommentMutation.isPending}
style={{ padding: "5px 14px", borderRadius: 6, background: "linear-gradient(135deg, #0d9488, #06b6d4)", border: "none", color: "white", cursor: commentBody.trim() ? "pointer" : "default", fontSize: 12, fontWeight: 600, fontFamily: "inherit", opacity: commentBody.trim() ? 1 : 0.35, transition: "opacity 0.12s" }}>
Post
</button>
</div>
)}
</div>
</div>
</div>
{/* Comment list */}
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
{comments?.map((c) => {
const tagStyle = c.tag ? TAG_STYLES[c.tag] : null;
const isNearPlayhead = isReady && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 5;
return (
<div key={c.id} id={`comment-${c.id}`}
style={{ marginBottom: 14, paddingBottom: 14, borderBottom: `1px solid rgba(255,255,255,0.04)`, borderRadius: isNearPlayhead ? 6 : undefined, background: isNearPlayhead ? "rgba(20,184,166,0.04)" : undefined, border: isNearPlayhead ? "1px solid rgba(20,184,166,0.12)" : undefined, padding: isNearPlayhead ? 8 : undefined }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<Avatar name={c.author_name} avatarUrl={c.author_avatar_url} authorId={c.author_id} size={21} />
<span style={{ fontSize: 12, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
{c.timestamp != null && (
<button onClick={() => seekTo(c.timestamp!)}
style={{ marginLeft: "auto", fontSize: 10, fontFamily: "monospace", color: "#2dd4bf", background: "rgba(20,184,166,0.1)", border: "none", borderRadius: 3, padding: "1px 5px", cursor: "pointer" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(20,184,166,0.2)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(20,184,166,0.1)")}
>
{formatTime(c.timestamp)}
</button>
)}
{tagStyle && <span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: tagStyle.bg, color: tagStyle.color }}>{c.tag}</span>}
</div>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.45)", lineHeight: 1.55 }}>{c.body}</p>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.18)", cursor: "pointer" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.5)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.18)")}
> Reply</span>
{me && c.author_id === me.id && (
<button onClick={() => deleteCommentMutation.mutate(c.id)}
style={{ background: "none", border: "none", color: "rgba(232,233,240,0.15)", cursor: "pointer", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#f87171")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.15)")}
>Delete</button>
)}
</div>
</div>
);
})}
{comments?.length === 0 && <p style={{ color: "rgba(232,233,240,0.22)", fontSize: 12 }}>No comments yet.</p>}
</div>
</div>
</div>
<style>{`
@keyframes pp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
`}</style>
</div>
);
}

View File

@@ -1,112 +1,87 @@
import { useRef, useState, useEffect } from "react"; import { useState } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom"; import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { api } from "../api/client"; import { api } from "../api/client";
import { logout } from "../api/auth"; import { logout } from "../api/auth";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import type { MemberRead } from "../api/auth"; import type { MemberRead } from "../api/auth";
import { usePlayerStore } from "../stores/playerStore"; import { usePlayerStore } from "../stores/playerStore";
import { useBandStore } from "../stores/bandStore";
import { TopBandBar } from "./TopBandBar";
// ── Icons (inline SVG) ────────────────────────────────────────────────────── // ── Icons ────────────────────────────────────────────────────────────────────
function IconWaveform() {
function IconMenu() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" /> <path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
</svg> </svg>
); );
} }
function IconLibrary() { function IconLibrary() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" /> <rect x="2" y="3.5" width="14" height="2" rx="1" fill="currentColor" />
<rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor" />
<rect x="2" y="12.5" width="14" height="2" rx="1" fill="currentColor" />
</svg> </svg>
); );
} }
function IconPlay() { function IconPlay() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" /> <path d="M5 3l11 6-11 6V3z" />
</svg> </svg>
); );
} }
function IconSettings() { function IconSettings() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="7" cy="7" r="2" /> <circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" /> <path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M3.7 3.7l1.06 1.06M13.24 13.24l1.06 1.06M14.3 3.7l-1.06 1.06M4.76 13.24l-1.06 1.06" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
function IconMembers() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<circle cx="5" cy="4.5" r="2" />
<path d="M1 12c0-2.2 1.8-3.5 4-3.5s4 1.3 4 3.5H1z" />
<circle cx="10.5" cy="4.5" r="1.5" opacity=".6" />
<path d="M10.5 8.5c1.4 0 2.5 1 2.5 2.5H9.5" opacity=".6" />
</svg>
);
}
function IconStorage() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="3" width="12" height="3" rx="1.5" />
<rect x="1" y="8" width="12" height="3" rx="1.5" />
<circle cx="11" cy="4.5" r=".75" fill="#0b0b0e" />
<circle cx="11" cy="9.5" r=".75" fill="#0b0b0e" />
</svg>
);
}
function IconChevron() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5l3 3 3-3" />
</svg> </svg>
); );
} }
function IconSignOut() { function IconSignOut() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" /> <path d="M5.5 2.5H3A1.5 1.5 0 0 0 1.5 4v7A1.5 1.5 0 0 0 3 12.5H5.5" />
<path d="M9 10l3-3-3-3M12 7H5" /> <path d="M10 10.5l3-3-3-3M13 7.5H6" />
</svg> </svg>
); );
} }
// ── NavItem ───────────────────────────────────────────────────────────────── // ── NavItem ─────────────────────────────────────────────────────────────────
interface NavItemProps { interface NavItemProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
badge?: number;
collapsed: boolean;
} }
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) { function NavItem({ icon, label, active, onClick, disabled, badge, collapsed }: NavItemProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const color = active const fg = active
? "#e8a22a" ? "#2dd4bf"
: disabled : disabled
? "rgba(255,255,255,0.18)" ? "rgba(255,255,255,0.16)"
: hovered : hovered
? "rgba(255,255,255,0.7)" ? "rgba(232,233,240,0.7)"
: "rgba(255,255,255,0.35)"; : "rgba(232,233,240,0.35)";
const bg = active const bg = active
? "rgba(232,162,42,0.12)" ? "rgba(20,184,166,0.12)"
: hovered && !disabled : hovered && !disabled
? "rgba(255,255,255,0.045)" ? "rgba(255,255,255,0.04)"
: "transparent"; : "transparent";
return ( return (
@@ -115,51 +90,69 @@ function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
disabled={disabled} disabled={disabled}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
title={collapsed ? label : undefined}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 9, gap: 10,
width: "100%", width: "100%",
padding: "7px 10px", padding: "9px 10px",
borderRadius: 7, borderRadius: 8,
border: "none", border: "none",
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
color, color: fg,
background: bg, background: bg,
fontSize: 12,
textAlign: "left", textAlign: "left",
marginBottom: 1, transition: "background 0.15s, color 0.15s",
transition: "background 0.12s, color 0.12s",
fontFamily: "inherit", fontFamily: "inherit",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}} }}
> >
{/* Active indicator */}
{active && (
<div style={{
position: "absolute", left: 0, top: "20%", bottom: "20%",
width: 2, borderRadius: "0 2px 2px 0",
background: "linear-gradient(to bottom, #0d9488, #22d3ee)",
}} />
)}
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{icon} {icon}
<span>{label}</span> </span>
{!collapsed && (
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
)}
{!collapsed && badge != null && badge > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 20,
background: "linear-gradient(135deg, #0d9488, #22d3ee)", color: "white",
flexShrink: 0,
}}>
{badge}
</span>
)}
</button> </button>
); );
} }
// ── Sidebar ──────────────────────────────────────────────────────────────── // ── Sidebar ───────────────────────────────────────────────────────────────────
export function Sidebar({ children }: { children: React.ReactNode }) { export function Sidebar({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [collapsed, setCollapsed] = useState(true);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"), queryFn: () => api.get<MemberRead>("/auth/me"),
}); });
// Derive active band from the current URL const { activeBandId } = useBandStore();
const bandMatch =
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const activeBandId = bandMatch?.params?.bandId ?? null;
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
// Nav active states
const isLibrary = !!( const isLibrary = !!(
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) || matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) || matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) ||
@@ -167,459 +160,121 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
); );
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname); const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
const isSettings = location.pathname.startsWith("/settings"); const isSettings = location.pathname.startsWith("/settings");
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
// Player state
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore(); const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId; const hasActiveSong = !!currentSongId && !!playerBandId;
// Close dropdown on outside click const sidebarWidth = collapsed ? 68 : 230;
useEffect(() => {
if (!dropdownOpen) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [dropdownOpen]);
const border = "rgba(255,255,255,0.06)"; const border = "rgba(255,255,255,0.06)";
return ( return (
<div <div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#080f0d", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
style={{
display: "flex", {/* ── Sidebar ── */}
height: "100vh", <aside style={{
overflow: "hidden", width: sidebarWidth,
background: "#0f0f12", minWidth: sidebarWidth,
color: "#eeeef2", background: "#0c1612",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
fontSize: 13,
}}
>
{/* ── Sidebar ──────────────────────────────────────────────────── */}
<aside
style={{
width: 210,
minWidth: 210,
background: "#0b0b0e",
borderRight: `1px solid ${border}`, borderRight: `1px solid ${border}`,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
overflow: "hidden", overflow: "hidden",
}} transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
>
{/* Logo */}
<div
style={{
padding: "17px 14px 14px",
display: "flex",
alignItems: "center",
gap: 10,
borderBottom: `1px solid ${border}`,
flexShrink: 0, flexShrink: 0,
}} zIndex: 20,
> }}>
<div
{/* Logo / toggle */}
<div style={{ padding: "18px 14px 14px", display: "flex", alignItems: "center", gap: 10, borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
<button
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? "Expand menu" : "Collapse menu"}
style={{ style={{
width: 28, width: 40, height: 40, borderRadius: 12, flexShrink: 0,
height: 28, background: "linear-gradient(135deg, #0d9488, #06b6d4)",
background: "#e8a22a", display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: 7, border: "none", cursor: "pointer",
display: "flex", boxShadow: "0 0 20px rgba(20,184,166,0.3)",
alignItems: "center", transition: "box-shadow 0.2s",
justifyContent: "center",
flexShrink: 0,
}} }}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 0 30px rgba(20,184,166,0.5)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 0 20px rgba(20,184,166,0.3)")}
> >
<IconWaveform /> <IconMenu />
</div> </button>
<div> {!collapsed && (
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}> <div style={{ overflow: "hidden" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
RehearsalHub RehearsalHub
</div> </div>
{activeBand && (
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
{activeBand.name}
</div>
)}
</div>
</div>
{/* Band switcher */}
<div
ref={dropdownRef}
style={{
padding: "10px 8px",
borderBottom: `1px solid ${border}`,
position: "relative",
flexShrink: 0,
}}
>
<button
onClick={() => setDropdownOpen((o) => !o)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
}}
>
<div
style={{
width: 26,
height: 26,
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{activeBand ? getInitials(activeBand.name) : "?"}
</div>
<span
style={{
flex: 1,
fontSize: 12,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{activeBand?.name ?? "Select a band"}
</span>
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
<IconChevron />
</span>
</button>
{dropdownOpen && (
<div
style={{
position: "absolute",
top: "calc(100% - 2px)",
left: 8,
right: 8,
background: "#18181e",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10,
padding: 6,
zIndex: 100,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
}}
>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => {
navigate(`/bands/${band.id}`);
setDropdownOpen(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
marginBottom: 1,
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 5,
background: "rgba(232,162,42,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{getInitials(band.name)}
</div>
<span
style={{
flex: 1,
fontSize: 12,
color: "rgba(255,255,255,0.62)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{band.name}
</span>
{band.id === activeBandId && (
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}></span>
)}
</button>
))}
<div
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: 4,
paddingTop: 4,
}}
>
<button
onClick={() => {
navigate("/");
setDropdownOpen(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
background: "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "rgba(255,255,255,0.35)",
fontSize: 12,
textAlign: "left",
fontFamily: "inherit",
}}
>
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
Create new band
</button>
</div>
</div> </div>
)} )}
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}> <nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
{activeBand && ( {activeBandId && (
<> <>
<SectionLabel>{activeBand.name}</SectionLabel> <NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
<NavItem
icon={<IconLibrary />}
label="Library"
active={isLibrary}
onClick={() => navigate(`/bands/${activeBand.id}`)}
/>
<NavItem <NavItem
icon={<IconPlay />} icon={<IconPlay />}
label="Player" label="Now Playing"
active={hasActiveSong && (isPlayer || isPlayerPlaying)} active={hasActiveSong && (isPlayer || isPlayerPlaying)}
onClick={() => { onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
if (hasActiveSong) {
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
}
}}
disabled={!hasActiveSong} disabled={!hasActiveSong}
collapsed={collapsed}
/> />
</> </>
)} )}
{activeBand && ( <div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
<> <NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
<SectionLabel style={{ paddingTop: 14 }}>Band Settings</SectionLabel>
<NavItem
icon={<IconMembers />}
label="Members"
active={isBandSettings && bandSettingsPanel === "members"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)}
/>
<NavItem
icon={<IconStorage />}
label="Storage"
active={isBandSettings && bandSettingsPanel === "storage"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)}
/>
<NavItem
icon={<IconSettings />}
label="Band Settings"
active={isBandSettings && bandSettingsPanel === "band"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)}
/>
</>
)}
<SectionLabel style={{ paddingTop: 14 }}>Account</SectionLabel>
<NavItem
icon={<IconSettings />}
label="Settings"
active={isSettings}
onClick={() => navigate("/settings")}
/>
</nav> </nav>
{/* User row */} {/* User row */}
<div <div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
style={{
padding: "10px",
borderTop: `1px solid ${border}`,
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}> <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button <button
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}
style={{ title={collapsed ? (me?.display_name ?? "Account") : undefined}
flex: 1, style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", background: "transparent", border: "none", borderRadius: 8, cursor: "pointer", color: "#e8e9f0", textAlign: "left", minWidth: 0, fontFamily: "inherit", overflow: "hidden" }}
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
background: "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
minWidth: 0,
fontFamily: "inherit",
}}
> >
{me?.avatar_url ? ( {me?.avatar_url ? (
<img <img src={me.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
src={me.avatar_url}
alt=""
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "cover",
flexShrink: 0,
}}
/>
) : ( ) : (
<div <div style={{ width: 28, height: 28, borderRadius: "50%", background: "rgba(52,211,153,0.15)", border: "1.5px solid rgba(52,211,153,0.3)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "rgba(232,162,42,0.18)",
border: "1.5px solid rgba(232,162,42,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{getInitials(me?.display_name ?? "?")} {getInitials(me?.display_name ?? "?")}
</div> </div>
)} )}
<span {!collapsed && (
style={{ <span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
flex: 1,
fontSize: 12,
color: "rgba(255,255,255,0.55)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{me?.display_name ?? "…"} {me?.display_name ?? "…"}
</span> </span>
)}
</button> </button>
{!collapsed && (
<button <button
onClick={() => logout()} onClick={() => logout()}
title="Sign out" title="Sign out"
style={{ style={{ flexShrink: 0, width: 30, height: 30, borderRadius: 7, background: "transparent", border: "1px solid transparent", cursor: "pointer", color: "rgba(255,255,255,0.2)", display: "flex", alignItems: "center", justifyContent: "center", transition: "border-color 0.12s, color 0.12s", padding: 0 }}
flexShrink: 0, onMouseEnter={(e) => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)"; e.currentTarget.style.color = "rgba(255,255,255,0.5)"; }}
width: 30, onMouseLeave={(e) => { e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.color = "rgba(255,255,255,0.2)"; }}
height: 30,
borderRadius: 7,
background: "transparent",
border: "1px solid transparent",
cursor: "pointer",
color: "rgba(255,255,255,0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "border-color 0.12s, color 0.12s",
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "transparent";
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
}}
> >
<IconSignOut /> <IconSignOut />
</button> </button>
)}
</div> </div>
</div> </div>
</aside> </aside>
{/* ── Main content ──────────────────────────────────────────────── */} {/* ── Main content ── */}
<main <main style={{ flex: 1, overflow: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#080f0d", minWidth: 0 }}>
style={{ <TopBandBar />
flex: 1, <div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
overflow: "auto",
display: "flex",
flexDirection: "column",
background: "#0f0f12",
}}
>
{children} {children}
</div>
</main> </main>
</div> </div>
); );
} }
function SectionLabel({
children,
style,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
fontSize: 10,
fontWeight: 500,
color: "rgba(255,255,255,0.2)",
textTransform: "uppercase",
letterSpacing: "0.7px",
padding: "0 6px 5px",
...style,
}}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,467 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
import { api } from "../api/client";
// ── Shared primitives ──────────────────────────────────────────────────────────
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 11px",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 7,
color: "#e8e9f0",
fontSize: 13,
fontFamily: "inherit",
outline: "none",
boxSizing: "border-box",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.06em",
color: "rgba(232,233,240,0.4)",
marginBottom: 5,
};
// ── Step indicator ─────────────────────────────────────────────────────────────
function StepDots({ current, total }: { current: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
width: i === current ? 16 : 6,
height: 6,
borderRadius: 3,
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
// ── Error banner ───────────────────────────────────────────────────────────────
function ErrorBanner({ msg }: { msg: string }) {
return (
<p style={{ margin: "0 0 14px", fontSize: 12, color: "#f87171", background: "rgba(248,113,113,0.08)", border: "1px solid rgba(248,113,113,0.2)", borderRadius: 6, padding: "8px 10px" }}>
{msg}
</p>
);
}
// ── Step 1: Storage setup ──────────────────────────────────────────────────────
interface Me { nc_configured: boolean; nc_url: string | null; nc_username: string | null; }
function StorageStep({ me, onNext }: { me: Me; onNext: () => void }) {
const qc = useQueryClient();
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => { urlRef.current?.focus(); }, []);
const saveMutation = useMutation({
mutationFn: () =>
api.patch("/auth/me/settings", {
nc_url: ncUrl.trim() || null,
nc_username: ncUsername.trim() || null,
nc_password: ncPassword || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
onNext();
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
const canSave = ncUrl.trim() && ncUsername.trim() && ncPassword;
return (
<>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>NEXTCLOUD URL</label>
<input
ref={urlRef}
value={ncUrl}
onChange={(e) => setNcUrl(e.target.value)}
style={inputStyle}
placeholder="https://cloud.example.com"
type="url"
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>USERNAME</label>
<input
value={ncUsername}
onChange={(e) => setNcUsername(e.target.value)}
style={inputStyle}
placeholder="your-nc-username"
autoComplete="username"
/>
</div>
<div style={{ marginBottom: 4 }}>
<label style={labelStyle}>APP PASSWORD</label>
<input
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
style={inputStyle}
type="password"
placeholder="Generate one in Nextcloud → Settings → Security"
autoComplete="current-password"
/>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Use an app password, not your account password.
</p>
{error && <ErrorBanner msg={error} />}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onNext}
style={{ padding: "8px 16px", background: "transparent", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}
>
Skip for now
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={!canSave || saveMutation.isPending}
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
</button>
</div>
</>
);
}
// ── Step 2: Band details ───────────────────────────────────────────────────────
function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: () => void }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [ncFolder, setNcFolder] = useState("");
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
const mutation = useMutation({
mutationFn: () =>
createBand({
name,
slug,
...(ncFolder.trim() ? { nc_base_path: ncFolder.trim() } : {}),
}),
onSuccess: (band) => {
qc.invalidateQueries({ queryKey: ["bands"] });
onClose();
navigate(`/bands/${band.id}`);
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
});
const handleNameChange = (v: string) => {
setName(v);
setSlug(v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
};
return (
<>
{!ncConfigured && (
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
Storage not configured recordings won't be scanned. You can set it up later in Settings Storage.
</div>
)}
{error && <ErrorBanner msg={error} />}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>BAND NAME</label>
<input
ref={nameRef}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
style={inputStyle}
placeholder="e.g. The Midnight Trio"
onKeyDown={(e) => { if (e.key === "Enter" && name && slug) mutation.mutate(); }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={labelStyle}>SLUG</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder="the-midnight-trio"
/>
</div>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 18, marginBottom: 22 }}>
<label style={labelStyle}>
NEXTCLOUD FOLDER{" "}
<span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400, letterSpacing: 0 }}>(optional)</span>
</label>
<input
value={ncFolder}
onChange={(e) => setNcFolder(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder={slug ? `bands/${slug}/` : "bands/my-band/"}
disabled={!ncConfigured}
/>
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
{ncConfigured
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
: "Connect storage first to set a folder."}
</p>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{ padding: "8px 16px", background: "transparent", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}
>
Cancel
</button>
<button
onClick={() => mutation.mutate()}
disabled={!name || !slug || mutation.isPending}
style={{ padding: "8px 18px", background: !name || !slug ? "rgba(20,184,166,0.3)" : "#14b8a6", border: "none", borderRadius: 7, color: !name || !slug ? "rgba(255,255,255,0.4)" : "#fff", cursor: !name || !slug ? "default" : "pointer", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{mutation.isPending ? "Creating…" : "Create Band"}
</button>
</div>
</>
);
}
// ── Create Band Modal (orchestrates steps) ─────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
const { data: me, isLoading } = useQuery<Me>({
queryKey: ["me"],
queryFn: () => api.get("/auth/me"),
});
// Start on step 0 (storage) if NC not configured, otherwise jump straight to step 1 (band)
const [step, setStep] = useState<0 | 1 | null>(null);
useEffect(() => {
if (me && step === null) setStep(me.nc_configured ? 1 : 0);
}, [me, step]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const totalSteps = me?.nc_configured === false ? 2 : 1;
const currentDot = step === 0 ? 0 : totalSteps - 1;
return (
<div
onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ background: "#112018", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14, padding: 28, width: 420, boxShadow: "0 24px 64px rgba(0,0,0,0.6)" }}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
<div>
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
{step === 0 ? "Connect storage" : "New band"}
</h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
</p>
</div>
{totalSteps > 1 && step !== null && (
<StepDots current={currentDot} total={totalSteps} />
)}
</div>
{isLoading || step === null ? (
<p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading</p>
) : step === 0 ? (
<StorageStep me={me!} onNext={() => setStep(1)} />
) : (
<BandStep ncConfigured={me?.nc_configured ?? false} onClose={onClose} />
)}
</div>
</div>
);
}
// ── TopBandBar ─────────────────────────────────────────────────────────────────
export function TopBandBar() {
const navigate = useNavigate();
const location = useLocation();
const [open, setOpen] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { activeBandId, setActiveBandId } = useBandStore();
// Sync store from URL when on a band page
const urlMatch =
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const urlBandId = urlMatch?.params?.bandId ?? null;
useEffect(() => {
if (urlBandId) setActiveBandId(urlBandId);
}, [urlBandId, setActiveBandId]);
const currentBandId = urlBandId ?? activeBandId;
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const border = "rgba(255,255,255,0.06)";
return (
<>
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
<div style={{
height: 44,
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 20px",
borderBottom: `1px solid ${border}`,
background: "#0c1612",
zIndex: 10,
}}>
{/* Band switcher */}
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "5px 10px",
background: open ? "rgba(255,255,255,0.06)" : "transparent",
border: `1px solid ${open ? "rgba(255,255,255,0.12)" : "transparent"}`,
borderRadius: 8,
cursor: "pointer", color: "#e8e9f0",
fontFamily: "inherit", fontSize: 13,
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => { if (!open) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (!open) e.currentTarget.style.background = "transparent"; }}
>
{activeBand ? (
<>
<div style={{
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
background: "rgba(20,184,166,0.15)",
border: "1px solid rgba(20,184,166,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 9, fontWeight: 800, color: "#2dd4bf",
}}>
{getInitials(activeBand.name)}
</div>
<span style={{ fontWeight: 600, fontSize: 13 }}>{activeBand.name}</span>
</>
) : (
<span style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>Select a band</span>
)}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", marginLeft: 2 }}>
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0,
minWidth: 220,
background: "#142420",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, padding: 6, zIndex: 100,
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => {
setActiveBandId(band.id);
navigate(`/bands/${band.id}`);
setOpen(false);
}}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 9px", marginBottom: 1,
background: band.id === currentBandId ? "rgba(20,184,166,0.1)" : "transparent",
border: "none", borderRadius: 6,
cursor: "pointer", color: "#e8e9f0",
textAlign: "left", fontFamily: "inherit",
transition: "background 0.12s",
}}
onMouseEnter={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "transparent"; }}
>
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(20,184,166,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#2dd4bf", flexShrink: 0 }}>
{getInitials(band.name)}
</div>
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{band.name}
</span>
{band.id === currentBandId && (
<span style={{ fontSize: 10, color: "#2dd4bf", flexShrink: 0 }}></span>
)}
</button>
))}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => { setOpen(false); setShowCreate(true); }}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.7)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
>
<span style={{ fontSize: 14, opacity: 0.6 }}>+</span>
New band
</button>
</div>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { RefObject } from "react";
// ── Hoist mocks so they're available in vi.mock factories ─────────────────────
const { audioServiceMock } = vi.hoisted(() => ({
audioServiceMock: {
initialize: vi.fn().mockResolvedValue(undefined),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
seekTo: vi.fn(),
getDuration: vi.fn(() => 0),
isWaveformReady: vi.fn(() => false),
},
}));
vi.mock("../services/audioService", () => ({
audioService: audioServiceMock,
}));
vi.mock("../stores/playerStore", () => ({
usePlayerStore: vi.fn((selector: (s: unknown) => unknown) =>
selector({ isPlaying: false, currentTime: 0, duration: 0 })
),
}));
// ── Import after mocks ─────────────────────────────────────────────────────────
import { useWaveform } from "./useWaveform";
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("useWaveform", () => {
beforeEach(() => {
vi.clearAllMocks();
audioServiceMock.initialize.mockResolvedValue(undefined);
});
it("forwards peaks to audioService.initialize when provided", async () => {
const containerRef: RefObject<HTMLDivElement> = {
current: document.createElement("div"),
};
const peaks = Array.from({ length: 500 }, (_, i) => i / 500);
renderHook(() =>
useWaveform(containerRef, {
url: "http://localhost/song.mp3",
peaksUrl: null,
peaks,
songId: "song-1",
bandId: "band-1",
})
);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(audioServiceMock.initialize).toHaveBeenCalledOnce();
const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0];
expect(passedPeaks).toEqual(peaks);
});
it("passes undefined when no peaks provided", async () => {
const containerRef: RefObject<HTMLDivElement> = {
current: document.createElement("div"),
};
renderHook(() =>
useWaveform(containerRef, {
url: "http://localhost/song.mp3",
peaksUrl: null,
songId: "song-1",
bandId: "band-1",
})
);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(audioServiceMock.initialize).toHaveBeenCalledOnce();
const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0];
expect(passedPeaks).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ import { usePlayerStore } from "../stores/playerStore";
export interface UseWaveformOptions { export interface UseWaveformOptions {
url: string | null; url: string | null;
peaksUrl: string | null; peaksUrl: string | null;
peaks?: number[] | null;
onReady?: (duration: number) => void; onReady?: (duration: number) => void;
onTimeUpdate?: (currentTime: number) => void; onTimeUpdate?: (currentTime: number) => void;
songId?: string | null; songId?: string | null;
@@ -39,7 +40,7 @@ export function useWaveform(
const initializeAudio = async () => { const initializeAudio = async () => {
try { try {
await audioService.initialize(containerRef.current!, options.url!); await audioService.initialize(containerRef.current!, options.url!, options.peaks ?? undefined);
// Restore playback if this song was already playing when the page loaded. // Restore playback if this song was already playing when the page loaded.
// Read as a one-time snapshot — these values must NOT be reactive deps or // Read as a one-time snapshot — these values must NOT be reactive deps or

View File

@@ -16,23 +16,30 @@ input, textarea, button, select {
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */ /* ── Design system (dark only — no light mode in v1) ─────────────────────── */
:root { :root {
--bg: #0f0f12; /* v2 dark-teal palette */
--bg: #080f0d;
--bg-card: #0c1612;
--bg-raised: #101c18;
--bg-hover: #142420;
--bg-subtle: rgba(255,255,255,0.025); --bg-subtle: rgba(255,255,255,0.025);
--bg-inset: rgba(255,255,255,0.04); --bg-inset: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.08); --border: rgba(255,255,255,0.06);
--border-subtle: rgba(255,255,255,0.05); --border-bright: rgba(255,255,255,0.12);
--text: #eeeef2; --border-subtle: rgba(255,255,255,0.04);
--text-muted: rgba(255,255,255,0.35); --text: #e8e9f0;
--text-subtle: rgba(255,255,255,0.22); --text-muted: rgba(232,233,240,0.55);
--accent: #e8a22a; --text-subtle: rgba(232,233,240,0.28);
--accent-hover: #f0b740; /* Teal accent */
--accent-bg: rgba(232,162,42,0.1); --accent: #14b8a6;
--accent-border: rgba(232,162,42,0.28); --accent-light: #2dd4bf;
--accent-fg: #0f0f12; --accent-hover: #10a89a;
--teal: #4dba85; --accent-bg: rgba(20,184,166,0.12);
--teal-bg: rgba(61,200,120,0.1); --accent-border: rgba(20,184,166,0.3);
--danger: #e07070; --accent-fg: #ffffff;
--danger-bg: rgba(220,80,80,0.1); --teal: #34d399;
--teal-bg: rgba(52,211,153,0.1);
--danger: #f43f5e;
--danger-bg: rgba(244,63,94,0.1);
} }
/* ── Responsive Layout ──────────────────────────────────────────────────── */ /* ── Responsive Layout ──────────────────────────────────────────────────── */
@@ -46,7 +53,7 @@ input, textarea, button, select {
/* Bottom Navigation Bar */ /* Bottom Navigation Bar */
nav[style*="position: fixed"] { nav[style*="position: fixed"] {
display: flex; display: flex;
background: #0b0b0e; background: #060d0b;
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 16px; padding: 8px 16px;
} }

View File

@@ -1,50 +1,16 @@
import { useState, useMemo } from "react"; import { useParams, useSearchParams } from "react-router-dom";
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getBand } from "../api/bands"; import { getBand } from "../api/bands";
import { api } from "../api/client"; import { LibraryPanel } from "../components/LibraryPanel";
import { PlayerPanel } from "../components/PlayerPanel";
interface SongSummary { // ── BandPage ──────────────────────────────────────────────────────────────────
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SessionSummary {
id: string;
date: string;
label: string | null;
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.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
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() { export function BandPage() {
const { bandId } = useParams<{ bandId: string }>(); const { bandId } = useParams<{ bandId: string }>();
const [librarySearch, setLibrarySearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams();
const [activePill, setActivePill] = useState<FilterPill>("all"); // selectedSongId is kept in URL as ?song=<id> so deep-links and browser back work
const selectedSongId = searchParams.get("song");
const { data: band, isLoading } = useQuery({ const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId], queryKey: ["band", bandId],
@@ -52,239 +18,37 @@ export function BandPage() {
enabled: !!bandId, enabled: !!bandId,
}); });
const { data: sessions } = useQuery({ function selectSong(songId: string) {
queryKey: ["sessions", bandId], setSearchParams({ song: songId }, { replace: false });
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`), }
enabled: !!bandId,
});
const { data: unattributedSongs } = useQuery({ function clearSong() {
queryKey: ["songs-unattributed", bandId], setSearchParams({}, { replace: false });
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`), }
enabled: !!bandId,
});
const filteredSessions = useMemo(() => { if (isLoading) return <div style={{ color: "rgba(232,233,240,0.35)", padding: 32 }}>Loading</div>;
return (sessions ?? []).filter((s) => { if (!band || !bandId) return <div style={{ color: "#f87171", padding: 32 }}>Band not found</div>;
if (!librarySearch) return true;
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
return haystack.includes(librarySearch.toLowerCase());
});
}, [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]);
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;
if (selectedSongId) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}> <div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<PlayerPanel
{/* ── Header ─────────────────────────────────────────────── */} key={selectedSongId}
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}> songId={selectedSongId}
{/* Title row + search + actions */} bandId={bandId}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}> onBack={clearSong}
<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>
</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>
{/* ── Scrollable content ────────────────────────────────── */} return (
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}> <div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<LibraryPanel
{/* Sessions — one date group per session */} bandId={bandId}
{filteredSessions.map((s) => ( selectedSongId={selectedSongId}
<div key={s.id} style={{ marginTop: 18 }}> onSelectSong={selectSong}
{/* 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
to={`/bands/${bandId}/sessions/${s.id}`}
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",
cursor: "pointer",
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)";
}}
>
{/* 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>
</div>
))}
{/* 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={{ 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={{ 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>
))}
{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>
)}
{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>
)}
</div>
</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>
))}
</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. Go to Storage settings to scan your Nextcloud folder."}
</p>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,320 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../test/helpers";
import { BandSettingsPage } from "./BandSettingsPage";
// ── Shared fixtures ───────────────────────────────────────────────────────────
const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" };
const BAND = {
id: "band-1",
name: "Loud Hands",
slug: "loud-hands",
genre_tags: ["post-rock", "math-rock"],
nc_folder_path: "bands/loud-hands/",
};
const MEMBERS_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" },
];
const MEMBERS_NON_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" },
];
const INVITES_RESPONSE = {
invites: [
{
id: "inv-1",
token: "abcdef1234567890abcd",
role: "member",
expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
is_used: false,
band_id: "band-1",
created_at: new Date().toISOString(),
used_at: null,
},
],
total: 1,
pending: 1,
};
// ── Mocks ─────────────────────────────────────────────────────────────────────
const {
mockGetBand,
mockApiGet,
mockApiPost,
mockApiPatch,
mockApiDelete,
mockListInvites,
mockRevokeInvite,
} = vi.hoisted(() => ({
mockGetBand: vi.fn(),
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
mockApiPatch: vi.fn(),
mockApiDelete: vi.fn(),
mockListInvites: vi.fn(),
mockRevokeInvite: vi.fn(),
}));
vi.mock("../api/bands", () => ({ getBand: mockGetBand }));
vi.mock("../api/invites", () => ({
listInvites: mockListInvites,
revokeInvite: mockRevokeInvite,
}));
vi.mock("../api/client", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
patch: mockApiPatch,
delete: mockApiDelete,
},
isLoggedIn: vi.fn().mockReturnValue(true),
}));
// ── Default mock implementations ─────────────────────────────────────────────
afterEach(() => vi.clearAllMocks());
function setupApiGet(members: typeof MEMBERS_ADMIN) {
mockApiGet.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve(ME);
if (url.includes("/members")) return Promise.resolve(members);
return Promise.resolve([]);
});
}
beforeEach(() => {
mockGetBand.mockResolvedValue(BAND);
setupApiGet(MEMBERS_ADMIN);
mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" });
mockApiPatch.mockResolvedValue(BAND);
mockApiDelete.mockResolvedValue({});
mockListInvites.mockResolvedValue(INVITES_RESPONSE);
mockRevokeInvite.mockResolvedValue({});
});
// ── Render helpers ────────────────────────────────────────────────────────────
function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) {
setupApiGet(members);
return renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: `/bands/band-1/settings/${panel}`,
});
}
// ── Routing ───────────────────────────────────────────────────────────────────
describe("BandSettingsPage — routing (TC-15 to TC-17)", () => {
it("TC-15: renders Storage panel for /settings/storage", async () => {
renderPanel("storage");
const heading = await screen.findByRole("heading", { name: /storage/i });
expect(heading).toBeTruthy();
});
it("TC-16: renders Band Settings panel for /settings/band", async () => {
renderPanel("band");
const heading = await screen.findByRole("heading", { name: /band settings/i });
expect(heading).toBeTruthy();
});
it("TC-17: unknown panel falls back to Members", async () => {
mockApiGet.mockResolvedValue(MEMBERS_ADMIN);
renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: "/bands/band-1/settings/unknown-panel",
});
const heading = await screen.findByRole("heading", { name: /members/i });
expect(heading).toBeTruthy();
});
});
// ── Members panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => {
it("TC-18: admin sees + Invite button", async () => {
renderPanel("members", MEMBERS_ADMIN);
const btn = await screen.findByText(/\+ invite/i);
expect(btn).toBeTruthy();
});
it("TC-19: non-admin does not see + Invite button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex"); // wait for members to load
expect(screen.queryByText(/\+ invite/i)).toBeNull();
});
it("TC-20: admin sees Remove button on non-admin members", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
expect(removeBtn).toBeTruthy();
});
it("TC-21: non-admin does not see any Remove button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText("Remove")).toBeNull();
});
it("TC-22: admin does not see Remove on admin-role members", async () => {
renderPanel("members", MEMBERS_ADMIN);
await screen.findByText("Steffen");
// Only one Remove button — for Alex (member), not Steffen (admin)
const removeBtns = screen.queryAllByText("Remove");
expect(removeBtns).toHaveLength(1);
});
it("TC-23: Pending Invites section hidden from non-admins", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText(/pending invites/i)).toBeNull();
});
});
// ── Members panel — functionality ─────────────────────────────────────────────
describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => {
it("TC-24: generate invite shows link in UI", async () => {
const token = "tok123abc456def789gh";
mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" });
renderPanel("members", MEMBERS_ADMIN);
const inviteBtn = await screen.findByText(/\+ invite/i);
fireEvent.click(inviteBtn);
const linkEl = await screen.findByText(new RegExp(token));
expect(linkEl).toBeTruthy();
});
it("TC-26: remove member calls DELETE endpoint", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
fireEvent.click(removeBtn);
await waitFor(() => {
expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2");
});
});
it("TC-27: revoke invite calls revokeInvite and refetches", async () => {
renderPanel("members", MEMBERS_ADMIN);
const revokeBtn = await screen.findByText("Revoke");
fireEvent.click(revokeBtn);
await waitFor(() => {
expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1");
});
});
});
// ── Storage panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => {
it("TC-29: admin sees Edit button", async () => {
renderPanel("storage", MEMBERS_ADMIN);
const edit = await screen.findByText("Edit");
expect(edit).toBeTruthy();
});
it("TC-30: non-admin does not see Edit button", async () => {
renderPanel("storage", MEMBERS_NON_ADMIN);
await screen.findByText(/scan path/i);
expect(screen.queryByText("Edit")).toBeNull();
});
it("TC-31: saving NC folder path calls PATCH and closes form", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
const input = screen.getByPlaceholderText(/bands\/loud-hands\//i);
fireEvent.change(input, { target: { value: "bands/custom-path/" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith(
"/bands/band-1",
{ nc_folder_path: "bands/custom-path/" }
);
});
});
it("TC-32: cancel edit closes form without calling PATCH", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockApiPatch).not.toHaveBeenCalled();
});
expect(screen.queryByText("Save")).toBeNull();
});
it("TC-33: shows default path when nc_folder_path is null", async () => {
mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null });
renderPanel("storage", MEMBERS_ADMIN);
const path = await screen.findByText("bands/loud-hands/");
expect(path).toBeTruthy();
});
});
// ── Band settings panel — access control ──────────────────────────────────────
describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => {
it("TC-34: admin sees Save changes button", async () => {
renderPanel("band", MEMBERS_ADMIN);
const btn = await screen.findByText(/save changes/i);
expect(btn).toBeTruthy();
});
it("TC-35: non-admin sees info text instead of Save button", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
// Wait for the band panel heading so we know the page has fully loaded
await screen.findByRole("heading", { name: /band settings/i });
// Once queries settle, the BandPanel-level info text should appear and Save should be absent
await waitFor(() => {
expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy();
});
expect(screen.queryByText(/save changes/i)).toBeNull();
});
it("TC-36: name field is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const input = await screen.findByDisplayValue("Loud Hands");
expect((input as HTMLInputElement).disabled).toBe(true);
});
it("TC-37: saving calls PATCH with name and genre_tags", async () => {
renderPanel("band", MEMBERS_ADMIN);
await screen.findByText(/save changes/i);
fireEvent.click(screen.getByText(/save changes/i));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", {
name: "Loud Hands",
genre_tags: ["post-rock", "math-rock"],
});
});
});
it("TC-38: adding a genre tag shows the new pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
const tagInput = await screen.findByPlaceholderText(/add genre tag/i);
fireEvent.change(tagInput, { target: { value: "punk" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
expect(screen.getByText("punk")).toBeTruthy();
});
it("TC-39: removing a genre tag removes its pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
// Find the × button next to "post-rock"
await screen.findByText("post-rock");
// There are two tags; find the × buttons
const removeButtons = screen.getAllByText("×");
fireEvent.click(removeButtons[0]);
expect(screen.queryByText("post-rock")).toBeNull();
});
it("TC-40: Delete band button is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const deleteBtn = await screen.findByText(/delete band/i);
expect((deleteBtn as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@@ -1,961 +0,0 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
import { listInvites, revokeInvite } from "../api/invites";
import type { MemberRead } from "../api/auth";
// ── Types ─────────────────────────────────────────────────────────────────────
interface BandMember {
id: string;
display_name: string;
email: string;
role: string;
joined_at: string;
}
interface BandInvite {
id: string;
token: string;
role: string;
expires_at: string | null;
is_used: boolean;
}
type Panel = "members" | "storage" | "band";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatExpiry(expiresAt: string | null | undefined): string {
if (!expiresAt) return "No expiry";
const date = new Date(expiresAt);
const diffHours = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60));
if (diffHours <= 0) return "Expired";
if (diffHours < 24) return `Expires in ${diffHours}h`;
return `Expires in ${Math.floor(diffHours / 24)}d`;
}
function isActive(invite: BandInvite): boolean {
return !invite.is_used && !!invite.expires_at && new Date(invite.expires_at) > new Date();
}
// ── Panel nav item ────────────────────────────────────────────────────────────
function PanelNavItem({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
width: "100%",
textAlign: "left",
padding: "7px 10px",
borderRadius: 7,
border: "none",
cursor: "pointer",
fontSize: 12,
fontFamily: "inherit",
marginBottom: 1,
background: active
? "rgba(232,162,42,0.1)"
: hovered
? "rgba(255,255,255,0.04)"
: "transparent",
color: active
? "#e8a22a"
: hovered
? "rgba(255,255,255,0.65)"
: "rgba(255,255,255,0.35)",
transition: "background 0.12s, color 0.12s",
}}
>
{label}
</button>
);
}
// ── Section title ─────────────────────────────────────────────────────────────
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "rgba(255,255,255,0.28)",
textTransform: "uppercase",
letterSpacing: "0.7px",
marginBottom: 12,
}}
>
{children}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "rgba(255,255,255,0.05)", margin: "20px 0" }} />;
}
// ── Members panel ─────────────────────────────────────────────────────────────
function MembersPanel({
bandId,
amAdmin,
members,
membersLoading,
}: {
bandId: string;
amAdmin: boolean;
members: BandMember[] | undefined;
membersLoading: boolean;
}) {
const qc = useQueryClient();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const { data: invitesData, isLoading: invitesLoading } = useQuery({
queryKey: ["invites", bandId],
queryFn: () => listInvites(bandId),
enabled: amAdmin,
retry: false,
});
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
qc.invalidateQueries({ queryKey: ["invites", bandId] });
},
});
const removeMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: string) => revokeInvite(inviteId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["invites", bandId] }),
});
const activeInvites = invitesData?.invites.filter(isActive) ?? [];
return (
<div>
{/* Member list */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<SectionTitle>Members</SectionTitle>
{amAdmin && (
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "4px 12px",
fontSize: 12,
fontFamily: "inherit",
}}
>
{inviteMutation.isPending ? "Generating…" : "+ Invite"}
</button>
)}
</div>
{inviteLink && (
<div
style={{
background: "rgba(232,162,42,0.06)",
border: "1px solid rgba(232,162,42,0.22)",
borderRadius: 8,
padding: "10px 14px",
marginBottom: 14,
}}
>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 11, margin: "0 0 5px" }}>
Invite link (copied to clipboard · valid 72h):
</p>
<code style={{ color: "#e8a22a", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{
display: "block",
marginTop: 6,
background: "none",
border: "none",
color: "rgba(255,255,255,0.28)",
cursor: "pointer",
fontSize: 11,
padding: 0,
fontFamily: "inherit",
}}
>
Dismiss
</button>
</div>
)}
{membersLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading</p>
) : (
<div style={{ display: "grid", gap: 6, marginBottom: 0 }}>
{members?.map((m) => (
<div
key={m.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: "50%",
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{m.display_name.slice(0, 2).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.72)" }}>{m.display_name}</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", marginTop: 1 }}>{m.email}</div>
</div>
<span
style={{
fontSize: 10,
fontFamily: "monospace",
padding: "2px 7px",
borderRadius: 3,
background: m.role === "admin" ? "rgba(232,162,42,0.1)" : "rgba(255,255,255,0.06)",
color: m.role === "admin" ? "#e8a22a" : "rgba(255,255,255,0.38)",
border: `1px solid ${m.role === "admin" ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
whiteSpace: "nowrap",
}}
>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMutation.mutate(m.id)}
disabled={removeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Remove
</button>
)}
</div>
))}
</div>
)}
{/* Role info cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "#e8a22a", marginBottom: 4 }}>Admin</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Upload, delete, manage members and storage
</div>
</div>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.55)", marginBottom: 4 }}>Member</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Listen, comment, annotate no upload or management
</div>
</div>
</div>
{/* Pending invites — admin only */}
{amAdmin && (
<>
<Divider />
<SectionTitle>Pending Invites</SectionTitle>
{invitesLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading invites</p>
) : activeInvites.length === 0 ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No pending invites.</p>
) : (
<div style={{ display: "grid", gap: 6 }}>
{activeInvites.map((invite) => (
<div
key={invite.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<code
style={{
fontSize: 11,
color: "rgba(255,255,255,0.35)",
fontFamily: "monospace",
}}
>
{invite.token.slice(0, 8)}{invite.token.slice(-4)}
</code>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 2 }}>
{formatExpiry(invite.expires_at)} · {invite.role}
</div>
</div>
<button
onClick={() =>
navigator.clipboard
.writeText(`${window.location.origin}/invite/${invite.token}`)
.catch(() => {})
}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 5,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Copy
</button>
<button
onClick={() => revokeMutation.mutate(invite.id)}
disabled={revokeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Revoke
</button>
</div>
))}
</div>
)}
<p style={{ fontSize: 11, color: "rgba(255,255,255,0.2)", marginTop: 8 }}>
No account needed to accept an invite.
</p>
</>
)}
</div>
);
}
// ── Storage panel ─────────────────────────────────────────────────────────────
function StoragePanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { slug: string; nc_folder_path: string | null };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [folderInput, setFolderInput] = useState("");
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
async function startScan() {
if (scanning) return;
setScanning(true);
setScanMsg(null);
setScanProgress("Starting scan…");
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try {
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText);
throw new Error(text || `HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: Record<string, unknown>;
try { event = JSON.parse(line); } catch { continue; }
if (event.type === "progress") {
setScanProgress(event.message as string);
} else if (event.type === "song" || event.type === "session") {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
} else if (event.type === "done") {
const s = event.stats as { found: number; imported: number; skipped: number };
if (s.imported > 0) {
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
} else if (s.found === 0) {
setScanMsg("No audio files found.");
} else {
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
}
setTimeout(() => setScanMsg(null), 6000);
} else if (event.type === "error") {
setScanMsg(`Scan error: ${event.message}`);
}
}
}
} catch (err) {
setScanMsg(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
setScanProgress(null);
}
}
const updateMutation = useMutation({
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditing(false);
},
});
const defaultPath = `bands/${band.slug}/`;
const currentPath = band.nc_folder_path ?? defaultPath;
return (
<div>
<SectionTitle>Nextcloud Scan Folder</SectionTitle>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings directly from your Nextcloud files are never copied to our
servers.
</p>
<div
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 9,
padding: "12px 16px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.22)", textTransform: "uppercase", letterSpacing: "0.6px", marginBottom: 4 }}>
Scan path
</div>
<code style={{ fontSize: 13, color: "#4dba85", fontFamily: "monospace" }}>
{currentPath}
</code>
</div>
{amAdmin && !editing && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditing(true); }}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "4px 10px",
fontSize: 11,
fontFamily: "inherit",
}}
>
Edit
</button>
)}
</div>
{editing && (
<div style={{ marginTop: 12 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={defaultPath}
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: "monospace",
boxSizing: "border-box",
outline: "none",
}}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateMutation.mutate(folderInput)}
disabled={updateMutation.isPending}
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",
}}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(false)}
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 14px",
fontSize: 12,
fontFamily: "inherit",
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Scan action */}
<div style={{ marginTop: 16 }}>
<button
onClick={startScan}
disabled={scanning}
style={{
background: scanning ? "transparent" : "rgba(61,200,120,0.08)",
border: `1px solid ${scanning ? "rgba(255,255,255,0.07)" : "rgba(61,200,120,0.25)"}`,
borderRadius: 6,
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
transition: "all 0.12s",
}}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
</div>
{scanning && scanProgress && (
<div style={{ marginTop: 10, 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>
)}
{scanMsg && (
<div style={{ marginTop: 10, 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>
);
}
// ── Band settings panel ───────────────────────────────────────────────────────
function BandPanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { name: string; slug: string; genre_tags: string[] };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [nameInput, setNameInput] = useState(band.name);
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(band.genre_tags);
const [saved, setSaved] = useState(false);
const updateMutation = useMutation({
mutationFn: (payload: { name?: string; genre_tags?: string[] }) =>
api.patch(`/bands/${bandId}`, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
qc.invalidateQueries({ queryKey: ["bands"] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
function addTag() {
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
}
function removeTag(t: string) {
setTags((prev) => prev.filter((x) => x !== t));
}
return (
<div>
<SectionTitle>Identity</SectionTitle>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Band name
</label>
<input
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
disabled={!amAdmin}
style={{
width: "100%",
padding: "8px 11px",
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",
opacity: amAdmin ? 1 : 0.5,
}}
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Genre tags
</label>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 6 }}>
{tags.map((t) => (
<span
key={t}
style={{
background: "rgba(140,90,220,0.1)",
color: "#a878e8",
fontSize: 11,
padding: "2px 8px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{t}
{amAdmin && (
<button
onClick={() => removeTag(t)}
style={{
background: "none",
border: "none",
color: "#a878e8",
cursor: "pointer",
fontSize: 13,
padding: 0,
lineHeight: 1,
fontFamily: "inherit",
}}
>
×
</button>
)}
</span>
))}
</div>
{amAdmin && (
<div style={{ display: "flex", gap: 6 }}>
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder="Add genre 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>
{amAdmin && (
<button
onClick={() => updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })}
disabled={updateMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: saved ? "#4dba85" : "#e8a22a",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
transition: "color 0.2s",
}}
>
{updateMutation.isPending ? "Saving…" : saved ? "Saved ✓" : "Save changes"}
</button>
)}
{!amAdmin && (
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.28)" }}>Only admins can edit band settings.</p>
)}
<Divider />
{/* Danger zone */}
<div
style={{
border: "1px solid rgba(220,80,80,0.18)",
borderRadius: 9,
padding: "14px 16px",
background: "rgba(220,80,80,0.04)",
}}
>
<div style={{ fontSize: 13, color: "#e07070", marginBottom: 3 }}>Delete this band</div>
<div style={{ fontSize: 11, color: "rgba(220,80,80,0.45)", marginBottom: 10 }}>
Removes all members and deletes comments. Storage files are NOT deleted.
</div>
<button
disabled={!amAdmin}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 6,
color: "#e07070",
cursor: amAdmin ? "pointer" : "default",
padding: "5px 12px",
fontSize: 11,
fontFamily: "inherit",
opacity: amAdmin ? 1 : 0.4,
}}
>
Delete band
</button>
</div>
</div>
);
}
// ── BandSettingsPage ──────────────────────────────────────────────────────────
export function BandSettingsPage() {
const { bandId, panel } = useParams<{ bandId: string; panel: string }>();
const navigate = useNavigate();
const activePanel: Panel =
panel === "storage" ? "storage" : panel === "band" ? "band" : "members";
const { data: band, isLoading: bandLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: members, isLoading: membersLoading } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"),
});
const amAdmin =
!!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false);
const go = (p: Panel) => navigate(`/bands/${bandId}/settings/${p}`);
if (bandLoading) {
return <div style={{ color: "rgba(255,255,255,0.28)", padding: 32 }}>Loading</div>;
}
if (!band) {
return <div style={{ color: "#e07070", padding: 32 }}>Band not found</div>;
}
return (
<div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
{/* ── Left panel nav ─────────────────────────────── */}
<div
style={{
width: 180,
minWidth: 180,
background: "#0b0b0e",
borderRight: "1px solid rgba(255,255,255,0.05)",
padding: "20px 10px",
display: "flex",
flexDirection: "column",
gap: 0,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 500,
color: "rgba(255,255,255,0.2)",
textTransform: "uppercase",
letterSpacing: "0.7px",
padding: "0 6px 8px",
}}
>
Band {band.name}
</div>
<PanelNavItem label="Members" active={activePanel === "members"} onClick={() => go("members")} />
<PanelNavItem label="Storage" active={activePanel === "storage"} onClick={() => go("storage")} />
<PanelNavItem label="Band Settings" active={activePanel === "band"} onClick={() => go("band")} />
</div>
{/* ── Panel content ──────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 580 }}>
{activePanel === "members" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Members
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Manage who has access to {band.name}'s recordings.
</p>
<MembersPanel
bandId={bandId!}
amAdmin={amAdmin}
members={members}
membersLoading={membersLoading}
/>
</>
)}
{activePanel === "storage" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Storage
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Configure where {band.name} stores recordings.
</p>
<StoragePanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
{activePanel === "band" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Band Settings
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Only admins can edit these settings.
</p>
<BandPanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AudioService } from "./audioService";
// ── WaveSurfer mock ────────────────────────────────────────────────────────────
const mockLoad = vi.fn();
const mockOn = vi.fn();
const mockUnAll = vi.fn();
const mockDestroy = vi.fn();
const mockGetDuration = vi.fn(() => 180);
const mockSetOptions = vi.fn();
const mockCreate = vi.fn();
vi.mock("wavesurfer.js", () => ({
default: {
create: (opts: unknown) => {
mockCreate(opts);
return {
load: mockLoad,
on: mockOn,
unAll: mockUnAll,
destroy: mockDestroy,
getDuration: mockGetDuration,
setOptions: mockSetOptions,
isPlaying: vi.fn(() => false),
};
},
},
}));
// ── Zustand store mock ─────────────────────────────────────────────────────────
vi.mock("../stores/playerStore", () => ({
usePlayerStore: {
getState: vi.fn(() => ({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: null,
currentBandId: null,
batchUpdate: vi.fn(),
setDuration: vi.fn(),
setCurrentSong: vi.fn(),
})),
subscribe: vi.fn(),
},
}));
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeContainer(): HTMLDivElement {
return document.createElement("div");
}
function triggerWaveSurferReady() {
// WaveSurfer fires the "ready" event via ws.on("ready", cb)
const readyCb = mockOn.mock.calls.find(([event]) => event === "ready")?.[1];
readyCb?.();
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("AudioService.initialize()", () => {
let service: AudioService;
beforeEach(() => {
AudioService.resetInstance();
service = AudioService.getInstance();
vi.clearAllMocks();
mockGetDuration.mockReturnValue(180);
});
afterEach(() => {
AudioService.resetInstance();
});
it("calls ws.load(url) without peaks when no peaks provided", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const initPromise = service.initialize(container, url);
triggerWaveSurferReady();
await initPromise;
expect(mockLoad).toHaveBeenCalledOnce();
const [calledUrl, calledPeaks] = mockLoad.mock.calls[0];
expect(calledUrl).toBe(url);
expect(calledPeaks).toBeUndefined();
});
it("calls ws.load(url, [Float32Array]) when peaks are provided", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const peaks = Array.from({ length: 500 }, (_, i) => i / 500);
const initPromise = service.initialize(container, url, peaks);
triggerWaveSurferReady();
await initPromise;
expect(mockLoad).toHaveBeenCalledOnce();
const [calledUrl, calledChannelData] = mockLoad.mock.calls[0];
expect(calledUrl).toBe(url);
expect(calledChannelData).toHaveLength(1);
expect(calledChannelData[0]).toBeInstanceOf(Float32Array);
expect(calledChannelData[0]).toHaveLength(500);
expect(calledChannelData[0][0]).toBeCloseTo(0);
expect(calledChannelData[0][499]).toBeCloseTo(499 / 500);
});
it("does not re-initialize for the same url and container", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const peaks = [0.5, 0.5, 0.5];
const p1 = service.initialize(container, url, peaks);
triggerWaveSurferReady();
await p1;
vi.clearAllMocks();
// Second call with same URL + container: should no-op
await service.initialize(container, url, peaks);
expect(mockLoad).not.toHaveBeenCalled();
});
});

View File

@@ -41,7 +41,7 @@ class AudioService {
return el; return el;
} }
public async initialize(container: HTMLElement, url: string): Promise<void> { public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise<void> {
if (!container) throw new Error('Container element is required'); if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required'); if (!url) throw new Error('Valid audio URL is required');
@@ -69,9 +69,9 @@ class AudioService {
// Fresh audio element per song. Lives on document.body so playback // Fresh audio element per song. Lives on document.body so playback
// continues even when the SongPage container is removed from the DOM. // continues even when the SongPage container is removed from the DOM.
media: this.mediaElement, media: this.mediaElement,
waveColor: "rgba(255,255,255,0.09)", waveColor: "rgba(20,184,166,0.18)",
progressColor: "#c8861a", progressColor: "#14b8a6",
cursorColor: "#e8a22a", cursorColor: "#2dd4bf",
barWidth: 2, barWidth: 2,
barRadius: 2, barRadius: 2,
height: 104, height: 104,
@@ -98,7 +98,14 @@ class AudioService {
ws.on('ready', () => { onReady().catch(reject); }); ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err)))); ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
// Pass pre-computed peaks to WaveSurfer so the waveform renders immediately
// without waiting for the full audio to decode (WaveSurfer v7 feature).
if (peaks && peaks.length > 0) {
ws.load(url, [new Float32Array(peaks)]);
} else {
ws.load(url); ws.load(url);
}
}); });
} }

View File

@@ -0,0 +1,18 @@
import { create } from "zustand";
interface BandState {
activeBandId: string | null;
setActiveBandId: (id: string | null) => void;
}
function load(): string | null {
try { return localStorage.getItem("rh_active_band_id"); } catch { return null; }
}
export const useBandStore = create<BandState>()((set) => ({
activeBandId: load(),
setActiveBandId: (id) => {
try { if (id) localStorage.setItem("rh_active_band_id", id); } catch { /* ignore */ }
set({ activeBandId: id });
},
}));

View File

@@ -26,6 +26,8 @@ class AudioVersionModel(Base):
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255)) nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text) cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text) waveform_url: Mapped[Optional[str]] = mapped_column(Text)
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer) duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10)) format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)

View File

@@ -21,7 +21,7 @@ from worker.db import AudioVersionModel, JobModel
from worker.pipeline.analyse_full import run_full_analysis from worker.pipeline.analyse_full import run_full_analysis
from worker.pipeline.analyse_range import run_range_analysis from worker.pipeline.analyse_range import run_range_analysis
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls from worker.pipeline.transcode import get_duration_ms, transcode_to_hls
from worker.pipeline.waveform import generate_waveform_file from worker.pipeline.waveform import extract_peaks, generate_waveform_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("worker") log = logging.getLogger("worker")
@@ -59,20 +59,23 @@ async def handle_transcode(payload: dict, session: AsyncSession, settings) -> No
hls_dir = os.path.join(tmp, "hls") hls_dir = os.path.join(tmp, "hls")
await transcode_to_hls(local_path, hls_dir) await transcode_to_hls(local_path, hls_dir)
waveform_path = os.path.join(tmp, "waveform.json") # Generate waveform peaks at two resolutions:
await generate_waveform_file(audio, waveform_path) # - 500-point full peaks passed to WaveSurfer for instant render in player
# - 100-point mini peaks for the library/overview SVG thumbnail
# TODO: Upload HLS segments and waveform back to Nextcloud / object storage loop = asyncio.get_event_loop()
# For now, store the local tmp path in the DB (replace with real upload logic) peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
hls_nc_path = f"hls/{version_id}" peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
waveform_nc_path = f"waveforms/{version_id}.json"
# NOTE: HLS upload to Nextcloud is not yet implemented.
# cdn_hls_base is intentionally left unchanged here — do NOT set it to a
# local tmp path that will be deleted. The stream endpoint falls back to
# nc_file_path (raw file from Nextcloud) when cdn_hls_base is null.
stmt = ( stmt = (
update(AudioVersionModel) update(AudioVersionModel)
.where(AudioVersionModel.id == version_id) .where(AudioVersionModel.id == version_id)
.values( .values(
cdn_hls_base=hls_nc_path, waveform_peaks=peaks_500,
waveform_url=waveform_nc_path, waveform_peaks_mini=peaks_100,
duration_ms=duration_ms, duration_ms=duration_ms,
analysis_status="running", analysis_status="running",
) )
@@ -102,9 +105,41 @@ async def handle_analyse_range(payload: dict, session: AsyncSession, settings) -
log.info("Range analysis complete for annotation %s", annotation_id) log.info("Range analysis complete for annotation %s", annotation_id)
async def handle_extract_peaks(payload: dict, session: AsyncSession, settings) -> None:
"""Lightweight job: download audio and (re-)compute waveform peaks only.
Used by the reindex endpoint to backfill peaks for versions that were
registered before peak computation was added, or after algorithm changes.
Does NOT transcode, generate HLS, or run full analysis.
"""
version_id = uuid.UUID(payload["version_id"])
nc_path = payload["nc_file_path"]
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, _sr, _local_path = await load_audio(nc_path, tmp, settings)
loop = asyncio.get_event_loop()
peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
stmt = (
update(AudioVersionModel)
.where(AudioVersionModel.id == version_id)
.values(
waveform_peaks=peaks_500,
waveform_peaks_mini=peaks_100,
)
)
await session.execute(stmt)
await session.commit()
log.info("extract_peaks complete for version %s", version_id)
HANDLERS = { HANDLERS = {
"transcode": handle_transcode, "transcode": handle_transcode,
"analyse_range": handle_analyse_range, "analyse_range": handle_analyse_range,
"extract_peaks": handle_extract_peaks,
} }

View File

@@ -0,0 +1,71 @@
"""Unit tests for handle_transcode waveform peaks storage."""
from unittest.mock import AsyncMock, MagicMock, patch, call
import uuid
import numpy as np
import pytest
@pytest.fixture
def mock_audio(sine_440hz):
audio, sr = sine_440hz
return audio, sr
@pytest.mark.asyncio
async def test_handle_transcode_stores_both_peak_resolutions(mock_audio):
"""After handle_transcode, waveform_peaks (500) and waveform_peaks_mini (100) are stored in DB."""
audio, sr = mock_audio
version_id = uuid.uuid4()
# Capture the statement passed to session.execute
executed_stmts = []
async def capture_execute(stmt):
executed_stmts.append(stmt)
return MagicMock()
mock_session = AsyncMock()
mock_session.execute = capture_execute
mock_session.commit = AsyncMock()
mock_settings = MagicMock()
mock_settings.nextcloud_url = "http://nc.test"
mock_settings.nextcloud_user = "user"
mock_settings.nextcloud_pass = "pass"
mock_settings.target_sample_rate = 44100
mock_settings.audio_tmp_dir = "/tmp"
payload = {
"version_id": str(version_id),
"nc_file_path": "/bands/test/songs/test/v1.wav",
}
with (
patch("worker.main.load_audio", return_value=(audio, sr, "/tmp/v1.wav")),
patch("worker.main.get_duration_ms", return_value=5000),
patch("worker.main.transcode_to_hls", new_callable=AsyncMock),
patch("worker.main.run_full_analysis", new_callable=AsyncMock),
):
from worker.main import handle_transcode
await handle_transcode(payload, mock_session, mock_settings)
assert len(executed_stmts) == 1, "Expected exactly one UPDATE statement"
stmt = executed_stmts[0]
# Extract the values dict from the SQLAlchemy Update statement
values = stmt._values
value_keys = {col.key for col, _ in values.items()}
assert "waveform_peaks" in value_keys, f"waveform_peaks not in UPDATE values: {value_keys}"
assert "waveform_peaks_mini" in value_keys, f"waveform_peaks_mini not in UPDATE values: {value_keys}"
# Resolve the actual peak lists from the BindParameter objects
peaks_500 = next(val.value for col, val in values.items() if col.key == "waveform_peaks")
peaks_100 = next(val.value for col, val in values.items() if col.key == "waveform_peaks_mini")
assert len(peaks_500) == 500, f"Expected 500 peaks, got {len(peaks_500)}"
assert len(peaks_100) == 100, f"Expected 100 mini peaks, got {len(peaks_100)}"
assert all(0.0 <= p <= 1.0 for p in peaks_500), "Full peaks out of [0, 1] range"
assert all(0.0 <= p <= 1.0 for p in peaks_100), "Mini peaks out of [0, 1] range"

View File

@@ -14,6 +14,12 @@ def test_extract_peaks_returns_correct_length(sine_440hz):
assert len(peaks) == 500 assert len(peaks) == 500
def test_extract_peaks_mini_returns_correct_length(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=100)
assert len(peaks) == 100
def test_extract_peaks_normalized_between_0_and_1(sine_440hz): def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=200) peaks = extract_peaks(audio, num_points=200)
@@ -21,6 +27,13 @@ def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
assert max(peaks) == pytest.approx(1.0, abs=0.01) assert max(peaks) == pytest.approx(1.0, abs=0.01)
def test_extract_peaks_mini_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=100)
assert all(0.0 <= p <= 1.0 for p in peaks)
assert max(peaks) == pytest.approx(1.0, abs=0.01)
def test_extract_peaks_empty_audio(): def test_extract_peaks_empty_audio():
audio = np.array([], dtype=np.float32) audio = np.array([], dtype=np.float32)
peaks = extract_peaks(audio, num_points=100) peaks = extract_peaks(audio, num_points=100)
@@ -28,6 +41,14 @@ def test_extract_peaks_empty_audio():
assert all(p == 0.0 for p in peaks) assert all(p == 0.0 for p in peaks)
def test_extract_peaks_custom_num_points(sine_440hz):
audio, _ = sine_440hz
for n in [50, 100, 250, 500]:
peaks = extract_peaks(audio, num_points=n)
assert len(peaks) == n, f"Expected {n} peaks, got {len(peaks)}"
assert all(0.0 <= p <= 1.0 for p in peaks)
def test_peaks_to_json_valid_structure(sine_440hz): def test_peaks_to_json_valid_structure(sine_440hz):
audio, _ = sine_440hz audio, _ = sine_440hz
peaks = extract_peaks(audio) peaks = extract_peaks(audio)
@@ -46,4 +67,5 @@ async def test_generate_waveform_file_writes_json(tmp_path, sine_440hz):
with open(output) as f: with open(output) as f:
data = json.load(f) data = json.load(f)
assert data["version"] == 2 assert data["version"] == 2
assert len(data["data"]) == 1000 # generate_waveform_file uses the default num_points=500
assert len(data["data"]) == 500