Move band management into dedicated settings pages

- Add BandSettingsPage (/bands/:id/settings/:panel) with Members,
  Storage, and Band Settings panels matching the mockup design
- Strip members list, invite controls, and NC folder config from
  BandPage — library view now focuses purely on recordings workflow
- Add band-scoped nav section to AppShell sidebar (Members, Storage,
  Band Settings) with correct per-panel active states
- Fix amAdmin bug: was checking if any member is admin; now correctly
  checks if the current user holds the admin role
- Add 31 vitest tests covering BandPage cleanliness, routing, access
  control (admin vs member), and per-panel mutation behaviour
- Add test:web, test:api:unit, test:feature (post-feature pipeline),
  and ci tasks to Taskfile; frontend tests run via podman node:20-alpine
- Add README with architecture overview, setup guide, and test docs
- Add @testing-library/dom and @testing-library/jest-dom to package.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-01 14:55:10 +02:00
parent 69c614cf62
commit 16bfdd2e90
12 changed files with 2428 additions and 465 deletions

280
README.md Normal file
View File

@@ -0,0 +1,280 @@
# RehearsalHub
A web platform for bands to relisten to recorded rehearsals, drop timestamped comments, annotate moments, and collaborate asynchronously — all on top of your own storage (Nextcloud, Google Drive, S3, local).
---
## Architecture
```
┌─────────┐ HTTP/WS ┌──────────────┐ asyncpg ┌──────────┐
│ React │ ──────────► │ FastAPI │ ──────────► │ Postgres │
│ (Vite) │ │ (Python) │ └──────────┘
└─────────┘ └──────┬───────┘
│ Redis pub/sub
┌──────────┴──────────┐
│ │
┌──────▼──────┐ ┌──────────▼──────┐
│ Audio Worker │ │ NC Watcher │
│ (waveforms) │ │ (file polling) │
└─────────────┘ └─────────────────┘
```
| Service | Language | Purpose |
|---|---|---|
| `web` | TypeScript / React | UI — player, library, settings |
| `api` | Python / FastAPI | REST + WebSocket backend |
| `worker` | Python | Audio analysis, waveform generation |
| `watcher` | Python | Polls Nextcloud for new files |
| `db` | PostgreSQL 16 | Primary datastore |
| `redis` | Redis 7 | Task queue, pub/sub |
Files are **never copied** to RehearsalHub servers. The platform reads recordings directly from your own storage.
---
## Prerequisites
| Tool | Purpose | Install |
|---|---|---|
| **Podman** + `podman-compose` | Container runtime | [podman.io](https://podman.io) |
| **uv** | Python package manager (backend) | `curl -Lsf https://astral.sh/uv/install.sh \| sh` |
| **Task** | Task runner (`Taskfile.yml`) | [taskfile.dev](https://taskfile.dev) |
| **Node 20** | Frontend (runs inside podman — not needed locally) | via `podman run node:20-alpine` |
> Node is only required inside a container. All frontend commands pull `node:20-alpine` via podman automatically.
---
## Quick start
### 1. Configure environment
```bash
cp .env.example .env
# Edit .env — set SECRET_KEY, INTERNAL_SECRET, Nextcloud credentials, domain
```
Generate secrets:
```bash
openssl rand -hex 32 # paste as SECRET_KEY
openssl rand -hex 32 # paste as INTERNAL_SECRET
```
### 2. Start all services
```bash
task up # starts db, redis, api, audio-worker, nc-watcher, web (nginx)
task migrate # run database migrations
```
Or for first-time setup with Nextcloud scaffolding:
```bash
task setup # up + wait for NC + configure NC + seed data
```
### 3. Open the app
Visit `http://localhost:8080` (or your configured `DOMAIN`).
---
## Development
Start the backend with hot reload and mount source directories:
```bash
task dev:detach # start db, redis, api, worker, watcher in dev mode (background)
task dev:web # start Vite dev server at http://localhost:3000 (proxies /api)
```
Or run both together:
```bash
task dev # foreground, streams all logs
```
Follow logs:
```bash
task logs # all services
task dev:logs SERVICE=api # single service
```
Restart a single service after a code change:
```bash
task dev:restart SERVICE=api
```
### Database migrations
```bash
# Apply pending migrations
task migrate
# Create a new migration from model changes
task migrate:auto M="add instrument field to band_member"
```
### Useful shells
```bash
task shell:api # bash in the API container
task shell:db # psql
task shell:redis # redis-cli
```
---
## Testing
### After every feature — run this
```bash
task test:feature
```
This runs the full **post-feature pipeline** (no external services required):
| Step | What it checks |
|---|---|
| `typecheck:web` | TypeScript compilation errors |
| `test:web` | React component tests (via podman + vitest) |
| `test:api:unit` | Python unit tests (no DB needed) |
| `test:worker` | Worker unit tests |
| `test:watcher` | Watcher unit tests |
Typical runtime: **~6090 seconds**.
---
### Full CI pipeline
Runs everything including integration tests against a live database.
**Requires services to be up** (`task dev:detach && task migrate`).
```bash
task ci
```
Stages:
```
lint ──► typecheck ──► test:web ──► test:api (unit + integration)
──► test:worker
──► test:watcher
```
---
### Individual test commands
```bash
# Frontend
task test:web # React/vitest tests (podman, no local Node needed)
task typecheck:web # TypeScript type check only
# Backend — unit (no services required)
task test:api:unit # API unit tests
task test:worker # Worker tests
task test:watcher # Watcher tests
# Backend — all (requires DB + services)
task test:api # unit + integration tests with coverage
task test # all backend suites
# Integration only
task test:integration # API integration tests (DB required)
# Lint
task lint # ruff + mypy (Python), eslint (TS)
task format # auto-format Python with ruff
```
---
### Frontend test details
Frontend tests run inside a `node:20-alpine` container via podman and do not require Node installed on the host:
```bash
task test:web
# equivalent to:
podman run --rm -v ./web:/app:Z -w /app node:20-alpine \
sh -c "npm install --legacy-peer-deps --silent && npm run test"
```
Tests use **vitest** + **@testing-library/react** and are located alongside the source files they test:
```
web/src/pages/
BandPage.tsx
BandPage.test.tsx ← 7 tests: library view cleanliness
BandSettingsPage.tsx
BandSettingsPage.test.tsx ← 24 tests: routing, access control, mutations
web/src/test/
setup.ts ← jest-dom matchers
helpers.tsx ← QueryClient + MemoryRouter wrapper
```
---
## Project structure
```
rehearshalhub/
├── api/ Python / FastAPI backend
│ ├── src/rehearsalhub/
│ │ ├── routers/ HTTP endpoints
│ │ ├── models/ SQLAlchemy ORM models
│ │ ├── repositories/ DB access layer
│ │ ├── services/ Business logic
│ │ └── schemas/ Pydantic request/response schemas
│ └── tests/
│ ├── unit/ Pure unit tests (no DB)
│ └── integration/ Full HTTP tests against a real DB
├── web/ TypeScript / React frontend
│ └── src/
│ ├── api/ API client functions
│ ├── components/ Shared components (AppShell, etc.)
│ ├── pages/ Route-level page components
│ └── test/ Test helpers and setup
├── worker/ Audio analysis service (Python)
├── watcher/ Nextcloud file polling service (Python)
├── scripts/ nc-setup.sh, seed.sh
├── traefik/ Reverse proxy config
├── docker-compose.yml Production compose
├── docker-compose.dev.yml Dev overrides (hot reload, source mounts)
├── Taskfile.yml Task runner (preferred)
└── Makefile Makefile aliases (same targets)
```
---
## Key design decisions
- **Storage is always yours.** RehearsalHub never copies audio files. It reads them directly from Nextcloud (or other providers) on demand.
- **Date is the primary axis.** The library groups recordings by session date. Filters narrow within that structure — they never flatten it.
- **Band switching is tenant-level.** Switching bands re-scopes the library, settings, and all band-specific views.
- **Settings are band-scoped.** Member management, storage configuration, and band identity live at `/bands/:id/settings`, not in the library view.
---
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `SECRET_KEY` | ✅ | 32-byte hex, JWT signing key |
| `INTERNAL_SECRET` | ✅ | 32-byte hex, service-to-service auth |
| `DATABASE_URL` | ✅ | PostgreSQL connection string |
| `REDIS_URL` | ✅ | Redis connection string |
| `NEXTCLOUD_URL` | ✅ | Full URL to your Nextcloud instance |
| `NEXTCLOUD_USER` | ✅ | Nextcloud service account username |
| `NEXTCLOUD_PASS` | ✅ | Nextcloud service account password |
| `DOMAIN` | ✅ | Public domain (used by Traefik TLS) |
| `ACME_EMAIL` | ✅ | Let's Encrypt email |
| `POSTGRES_DB` | ✅ | Database name |
| `POSTGRES_USER` | ✅ | Database user |
| `POSTGRES_PASSWORD` | ✅ | Database password |
See `.env.example` for the full template.

View File

@@ -87,16 +87,51 @@ tasks:
# ── Testing ─────────────────────────────────────────────────────────────────── # ── Testing ───────────────────────────────────────────────────────────────────
# Run this after every feature branch — fast, no external services required.
test:feature:
desc: "Post-feature pipeline: typecheck + frontend tests + backend unit tests (no services needed)"
cmds:
- task: typecheck:web
- task: test:web
- task: test:api:unit
- task: test:worker
- task: test:watcher
# Full CI pipeline — runs everything including integration tests.
# Requires: services up (task dev:detach), DB migrated.
ci:
desc: "Full CI pipeline: lint + typecheck + all tests (requires services running)"
cmds:
- task: lint
- task: typecheck:web
- task: test:web
- task: test:api
- task: test:worker
- task: test:watcher
test: test:
desc: Run all tests desc: Run all backend tests (unit + integration)
deps: [test:api, test:worker, test:watcher] deps: [test:api, test:worker, test:watcher]
test:web:
desc: Run frontend unit tests (via podman — no local Node required)
dir: web
cmds:
- podman run --rm -v "$(pwd)":/app:Z -w /app node:20-alpine
sh -c "npm install --legacy-peer-deps --silent && npm run test"
test:api: test:api:
desc: Run API tests with coverage desc: Run all API tests with coverage (unit + integration)
dir: api dir: api
cmds: cmds:
- uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing - uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing
test:api:unit:
desc: Run API unit tests only (no database or external services required)
dir: api
cmds:
- uv run pytest tests/unit/ -v -m "not integration"
test:worker: test:worker:
desc: Run worker tests with coverage desc: Run worker tests with coverage
dir: worker dir: worker
@@ -110,7 +145,7 @@ tasks:
- uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing - uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing
test:integration: test:integration:
desc: Run integration tests desc: Run integration tests (requires services running)
dir: api dir: api
cmds: cmds:
- uv run pytest tests/integration/ -v -m integration - uv run pytest tests/integration/ -v -m integration

138
web/package-lock.json generated
View File

@@ -17,6 +17,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
@@ -32,6 +34,13 @@
"vitest": "^2.1.1" "vitest": "^2.1.1"
} }
}, },
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@@ -1495,7 +1504,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -1510,6 +1518,43 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "16.3.2", "version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
@@ -1557,8 +1602,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -1623,14 +1667,14 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -2132,7 +2176,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -2161,14 +2204,13 @@
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.0", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "engines": {
"dependencies": { "node": ">= 0.4"
"dequal": "^2.0.3"
} }
}, },
"node_modules/assertion-error": { "node_modules/assertion-error": {
@@ -2414,6 +2456,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssstyle": { "node_modules/cssstyle": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -2439,7 +2488,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": { "node_modules/data-urls": {
@@ -2514,18 +2563,16 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
@@ -3264,6 +3311,16 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3490,7 +3547,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -3538,6 +3594,16 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3776,7 +3842,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -3792,7 +3857,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -3840,8 +3904,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
@@ -3885,6 +3948,20 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4040,6 +4117,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",

View File

@@ -23,6 +23,8 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",

View File

@@ -6,6 +6,7 @@ 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";
@@ -50,6 +51,18 @@ export default function App() {
</ShellRoute> </ShellRoute>
} }
/> />
<Route
path="/bands/:bandId/settings"
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

@@ -54,6 +54,28 @@ function IconSettings() {
); );
} }
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() { function IconChevron() {
return ( return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -157,6 +179,8 @@ export function AppShell({ 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;
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
@@ -425,7 +449,31 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</> </>
)} )}
<SectionLabel style={{ paddingTop: activeBand ? 14 : 0 }}>Account</SectionLabel> {activeBand && (
<>
<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 <NavItem
icon={<IconSettings />} icon={<IconSettings />}
label="Settings" label="Settings"

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../test/helpers";
import { BandPage } from "./BandPage";
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("../api/bands", () => ({
getBand: vi.fn().mockResolvedValue({
id: "band-1",
name: "Loud Hands",
slug: "loud-hands",
genre_tags: ["post-rock"],
nc_folder_path: null,
}),
}));
vi.mock("../api/client", () => ({
api: {
get: vi.fn().mockImplementation((url: string) => {
if (url.includes("/sessions")) {
return Promise.resolve([
{ id: "s1", date: "2026-03-31", label: "Late Night Jam", recording_count: 3 },
]);
}
if (url.includes("/songs/search")) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
isLoggedIn: vi.fn().mockReturnValue(true),
}));
const renderBandPage = () =>
renderWithProviders(<BandPage />, {
path: "/bands/:bandId",
route: "/bands/band-1",
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("BandPage — cleanliness (TC-01 to TC-07)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("TC-01: does not render a member list", async () => {
renderBandPage();
// Allow queries to settle
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/members/i)).toBeNull();
});
it("TC-02: does not render an invite button", async () => {
renderBandPage();
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/\+ invite/i)).toBeNull();
});
it("TC-03: does not render the Nextcloud folder config widget", async () => {
renderBandPage();
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/scan path/i)).toBeNull();
expect(screen.queryByText(/nextcloud scan folder/i)).toBeNull();
});
it("TC-04: renders sessions grouped by date", async () => {
renderBandPage();
// Sessions appear after the query resolves
const sessionEl = await screen.findByText("Late Night Jam");
expect(sessionEl).toBeTruthy();
});
it("TC-05: renders the Scan Nextcloud action button", async () => {
renderBandPage();
const btn = await screen.findByText(/scan nextcloud/i);
expect(btn).toBeTruthy();
});
it("TC-06: renders the + New Song button", async () => {
renderBandPage();
const btn = await screen.findByText(/\+ new song/i);
expect(btn).toBeTruthy();
});
it("TC-07: renders both By Date and Search tabs", async () => {
renderBandPage();
const byDate = await screen.findByText(/by date/i);
const search = await screen.findByText(/^search$/i);
expect(byDate).toBeTruthy();
expect(search).toBeTruthy();
});
});

View File

@@ -3,7 +3,6 @@ import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands"; import { getBand } from "../api/bands";
import { api } from "../api/client"; import { api } from "../api/client";
import { InviteManagement } from "../components/InviteManagement";
interface SongSummary { interface SongSummary {
id: string; id: string;
@@ -15,21 +14,6 @@ interface SongSummary {
version_count: number; version_count: number;
} }
interface BandMember {
id: string;
display_name: string;
email: string;
role: string;
joined_at: string;
}
interface BandInvite {
id: string;
token: string;
role: string;
expires_at: string;
}
interface SessionSummary { interface SessionSummary {
id: string; id: string;
date: string; date: string;
@@ -56,9 +40,6 @@ export function BandPage() {
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null); const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null); const [scanMsg, setScanMsg] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [editingFolder, setEditingFolder] = useState(false);
const [folderInput, setFolderInput] = useState("");
// Search state // Search state
const [searchQ, setSearchQ] = useState(""); const [searchQ, setSearchQ] = useState("");
@@ -87,12 +68,6 @@ export function BandPage() {
enabled: !!bandId && tab === "dates", enabled: !!bandId && tab === "dates",
}); });
const { data: members } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
// Search results — only fetch when user has triggered a search // Search results — only fetch when user has triggered a search
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (searchQ) searchParams.set("q", searchQ); if (searchQ) searchParams.set("q", searchQ);
@@ -127,7 +102,6 @@ export function BandPage() {
const url = `/api/v1/bands/${bandId}/nc-scan/stream`; const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try { try {
// credentials: "include" sends the rh_token httpOnly cookie automatically
const resp = await fetch(url, { credentials: "include" }); const resp = await fetch(url, { credentials: "include" });
if (!resp.ok || !resp.body) { if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText); const text = await resp.text().catch(() => resp.statusText);
@@ -178,31 +152,6 @@ export function BandPage() {
} }
} }
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
},
});
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const updateFolderMutation = useMutation({
mutationFn: (nc_folder_path: string) =>
api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditingFolder(false);
},
});
const amAdmin = members?.some((m) => m.role === "admin") ?? false;
function addTag() { function addTag() {
const t = searchTagInput.trim(); const t = searchTagInput.trim();
if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]); if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]);
@@ -217,187 +166,166 @@ export function BandPage() {
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>; if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
return ( return (
<div style={{ padding: 32 }}> <div style={{ padding: "20px 32px", maxWidth: 760, margin: "0 auto" }}>
<div style={{ maxWidth: 720, margin: "0 auto" }}> {/* ── Page header ───────────────────────────────────────── */}
{/* Band header */} <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div style={{ marginBottom: 24 }}> <div>
<h1 style={{ color: "var(--accent)", fontFamily: "monospace", margin: "0 0 4px" }}>{band.name}</h1> <h1 style={{ color: "#eeeef2", fontSize: 17, fontWeight: 500, margin: "0 0 4px" }}>{band.name}</h1>
{band.genre_tags.length > 0 && ( {band.genre_tags.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 8 }}> <div style={{ display: "flex", gap: 4, marginTop: 6 }}>
{band.genre_tags.map((t: string) => ( {band.genre_tags.map((t: string) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 10, padding: "2px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span
))} key={t}
</div> style={{
)} background: "rgba(140,90,220,0.1)",
</div> color: "#a878e8",
fontSize: 10,
{/* Nextcloud folder */} padding: "1px 7px",
<div style={{ marginBottom: 24, background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: "12px 16px" }}> borderRadius: 12,
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}> }}
<div>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>NEXTCLOUD SCAN FOLDER</span>
<div style={{ fontFamily: "monospace", color: "var(--teal)", fontSize: 13, marginTop: 4 }}>
{band.nc_folder_path ?? `bands/${band.slug}/`}
</div>
</div>
{amAdmin && !editingFolder && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingFolder(true); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "4px 10px", fontSize: 11 }}
> >
Edit {t}
</button>
)}
</div>
{editingFolder && (
<div style={{ marginTop: 10 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={`bands/${band.slug}/`}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateFolderMutation.mutate(folderInput)}
disabled={updateFolderMutation.isPending}
style={{ background: "var(--teal)", border: "none", borderRadius: 6, color: "var(--bg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }}
>
Save
</button>
<button
onClick={() => setEditingFolder(false)}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Members */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Members</h2>
{amAdmin && (
<>
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--accent)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
>
+ Invite
</button>
{/* Search for users to invite (new feature) */}
{/* Temporarily hide user search until backend supports it */}
</>
)}
</div>
{inviteLink && (
<div style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)", borderRadius: 8, padding: "10px 14px", marginBottom: 12 }}>
<p style={{ color: "var(--text-muted)", fontSize: 11, margin: "0 0 6px" }}>Invite link (copied to clipboard, valid 72h):</p>
<code style={{ color: "var(--accent)", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{ display: "block", marginTop: 8, background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Dismiss
</button>
</div>
)}
<div style={{ display: "grid", gap: 6 }}>
{members?.map((m) => (
<div
key={m.id}
style={{ background: "var(--bg-subtle)", border: "1px solid var(--border-subtle)", borderRadius: 8, padding: "10px 14px", display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<div>
<span style={{ fontWeight: 500 }}>{m.display_name}</span>
<span style={{ color: "var(--text-muted)", fontSize: 11, marginLeft: 10 }}>{m.email}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{
fontSize: 10, fontFamily: "monospace", padding: "2px 6px", borderRadius: 3,
background: m.role === "admin" ? "var(--accent-bg)" : "var(--bg-inset)",
color: m.role === "admin" ? "var(--accent)" : "var(--text-muted)",
border: `1px solid ${m.role === "admin" ? "var(--accent-border)" : "var(--border)"}`,
}}>
{m.role}
</span> </span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMemberMutation.mutate(m.id)}
style={{ background: "none", border: "none", color: "var(--danger)", cursor: "pointer", fontSize: 11, padding: 0 }}
>
Remove
</button>
)}
</div>
</div>
))} ))}
</div> </div>
)}
{/* Admin: Invite Management Section (new feature) */}
{amAdmin && <InviteManagement bandId={bandId!} />}
</div> </div>
{/* Recordings header */} <div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Recordings</h2>
<div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={startScan} onClick={startScan}
disabled={scanning} disabled={scanning}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }} style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
opacity: scanning ? 0.6 : 1,
}}
> >
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"} {scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button> </button>
<button <button
onClick={() => { setShowCreate(!showCreate); setError(null); }} onClick={() => { setShowCreate(!showCreate); setError(null); }}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "6px 14px", fontSize: 12, fontWeight: 600 }} style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "6px 14px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
}}
> >
+ New Song + New Song
</button> </button>
</div> </div>
</div> </div>
{/* ── Scan feedback ─────────────────────────────────────── */}
{scanning && scanProgress && ( {scanning && scanProgress && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, color: "var(--text-muted)", fontSize: 12, padding: "8px 14px", marginBottom: 8, fontFamily: "monospace" }}> <div
style={{
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
color: "rgba(255,255,255,0.42)",
fontSize: 12,
padding: "8px 14px",
marginBottom: 10,
fontFamily: "monospace",
}}
>
{scanProgress} {scanProgress}
</div> </div>
)} )}
{scanMsg && ( {scanMsg && (
<div style={{ background: "var(--teal-bg)", border: "1px solid var(--teal)", borderRadius: 8, color: "var(--teal)", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}> <div
style={{
background: "rgba(61,200,120,0.06)",
border: "1px solid rgba(61,200,120,0.25)",
borderRadius: 8,
color: "#4dba85",
fontSize: 12,
padding: "8px 14px",
marginBottom: 14,
}}
>
{scanMsg} {scanMsg}
</div> </div>
)} )}
{/* ── New song form ─────────────────────────────────────── */}
{showCreate && ( {showCreate && (
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 20, marginBottom: 16 }}> <div
{error && <p style={{ color: "var(--danger)", fontSize: 13, marginBottom: 12 }}>{error}</p>} style={{
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 11, marginBottom: 6 }}>SONG TITLE</label> background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
padding: 20,
marginBottom: 18,
}}
>
{error && <p style={{ color: "#e07070", fontSize: 13, marginBottom: 12 }}>{error}</p>}
<label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 11, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 6 }}>
Song title
</label>
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()}
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }} style={{
width: "100%",
padding: "8px 12px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 7,
color: "#eeeef2",
marginBottom: 12,
fontSize: 14,
fontFamily: "inherit",
boxSizing: "border-box",
outline: "none",
}}
autoFocus autoFocus
/> />
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!title} disabled={!title}
style={{ background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "8px 18px", fontWeight: 600, fontSize: 13 }} style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: title ? "pointer" : "default",
padding: "7px 18px",
fontWeight: 600,
fontSize: 13,
fontFamily: "inherit",
opacity: title ? 1 : 0.4,
}}
> >
Create Create
</button> </button>
<button <button
onClick={() => { setShowCreate(false); setError(null); }} onClick={() => { setShowCreate(false); setError(null); }}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", padding: "8px 18px", fontSize: 13 }} style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontFamily: "inherit",
}}
> >
Cancel Cancel
</button> </button>
@@ -405,8 +333,8 @@ export function BandPage() {
</div> </div>
)} )}
{/* Tabs */} {/* ── Tabs ──────────────────────────────────────────────── */}
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1px solid var(--border)" }}> <div style={{ display: "flex", borderBottom: "1px solid rgba(255,255,255,0.06)", marginBottom: 18 }}>
{(["dates", "search"] as const).map((t) => ( {(["dates", "search"] as const).map((t) => (
<button <button
key={t} key={t}
@@ -414,13 +342,15 @@ export function BandPage() {
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",
borderBottom: `2px solid ${tab === t ? "var(--accent)" : "transparent"}`, borderBottom: `2px solid ${tab === t ? "#e8a22a" : "transparent"}`,
color: tab === t ? "var(--accent)" : "var(--text-muted)", color: tab === t ? "#e8a22a" : "rgba(255,255,255,0.35)",
cursor: "pointer", cursor: "pointer",
padding: "8px 16px", padding: "8px 16px",
fontSize: 13, fontSize: 13,
fontWeight: tab === t ? 600 : 400, fontWeight: tab === t ? 600 : 400,
marginBottom: -1, marginBottom: -1,
fontFamily: "inherit",
transition: "color 0.12s",
}} }}
> >
{t === "dates" ? "By Date" : "Search"} {t === "dates" ? "By Date" : "Search"}
@@ -428,62 +358,100 @@ export function BandPage() {
))} ))}
</div> </div>
{/* By Date tab */} {/* ── By Date tab ───────────────────────────────────────── */}
{tab === "dates" && ( {tab === "dates" && (
<div style={{ display: "grid", gap: 6 }}> <div style={{ display: "grid", gap: 4 }}>
{sessions?.map((s) => ( {sessions?.map((s) => (
<Link <Link
key={s.id} key={s.id}
to={`/bands/${bandId}/sessions/${s.id}`} to={`/bands/${bandId}/sessions/${s.id}`}
style={{ style={{
background: "var(--bg-subtle)", background: "rgba(255,255,255,0.02)",
border: "1px solid var(--border-subtle)", border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8, borderRadius: 8,
padding: "14px 18px", padding: "13px 16px",
textDecoration: "none", textDecoration: "none",
color: "var(--text)", color: "#eeeef2",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 12, gap: 12,
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)";
}} }}
> >
<div> <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
<span style={{ fontFamily: "monospace", color: "var(--text-muted)", fontSize: 10, marginRight: 8 }}>{weekday(s.date)}</span> <span
<span style={{ fontWeight: 500 }}>{formatDate(s.date)}</span> style={{
fontFamily: "monospace",
color: "rgba(255,255,255,0.28)",
fontSize: 10,
flexShrink: 0,
}}
>
{weekday(s.date)}
</span>
<span style={{ fontWeight: 500, color: "#d8d8e4" }}>{formatDate(s.date)}</span>
{s.label && ( {s.label && (
<span style={{ color: "var(--teal)", fontSize: 12, marginLeft: 10 }}>{s.label}</span> <span style={{ color: "#4dba85", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label}
</span>
)} )}
</div> </div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}> <span
style={{
color: "rgba(255,255,255,0.28)",
fontSize: 12,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span> </span>
</Link> </Link>
))} ))}
{sessions?.length === 0 && !unattributedSongs?.length && ( {sessions?.length === 0 && !unattributedSongs?.length && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}> <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "8px 0" }}>
No sessions yet. Scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>. No sessions yet. Scan Nextcloud or create a song to get started.
</p> </p>
)} )}
{/* Songs not linked to any dated session */} {/* Unattributed songs */}
{!!unattributedSongs?.length && ( {!!unattributedSongs?.length && (
<div style={{ marginTop: sessions?.length ? 24 : 0 }}> <div style={{ marginTop: sessions?.length ? 24 : 0 }}>
<div style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}> <div
UNATTRIBUTED RECORDINGS style={{
color: "rgba(255,255,255,0.2)",
fontSize: 10,
fontFamily: "monospace",
letterSpacing: 1,
textTransform: "uppercase",
marginBottom: 8,
paddingLeft: 2,
}}
>
Unattributed Recordings
</div> </div>
<div style={{ display: "grid", gap: 6 }}> <div style={{ display: "grid", gap: 4 }}>
{unattributedSongs.map((song) => ( {unattributedSongs.map((song) => (
<Link <Link
key={song.id} key={song.id}
to={`/bands/${bandId}/songs/${song.id}`} to={`/bands/${bandId}/songs/${song.id}`}
style={{ style={{
background: "var(--bg-subtle)", background: "rgba(255,255,255,0.02)",
border: "1px solid var(--border-subtle)", border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8, borderRadius: 8,
padding: "14px 18px", padding: "13px 16px",
textDecoration: "none", textDecoration: "none",
color: "var(--text)", color: "#eeeef2",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
@@ -491,17 +459,40 @@ export function BandPage() {
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div> <div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => ( {song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span
key={t}
style={{
background: "rgba(61,200,120,0.08)",
color: "#4dba85",
fontSize: 9,
padding: "1px 6px",
borderRadius: 3,
fontFamily: "monospace",
}}
>
{t}
</span>
))} ))}
</div> </div>
</div> </div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}> <div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span> <span
{song.version_count} version{song.version_count !== 1 ? "s" : ""} style={{
background: "rgba(255,255,255,0.05)",
borderRadius: 4,
padding: "2px 6px",
marginRight: 8,
fontFamily: "monospace",
fontSize: 10,
}}
>
{song.status}
</span> </span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div>
</Link> </Link>
))} ))}
</div> </div>
@@ -510,69 +501,96 @@ export function BandPage() {
</div> </div>
)} )}
{/* Search tab */} {/* ── Search tab ────────────────────────────────────────── */}
{tab === "search" && ( {tab === "search" && (
<div> <div>
{/* Filters */} <div
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, marginBottom: 16 }}> style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.06)",
borderRadius: 8,
padding: 16,
marginBottom: 16,
}}
>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div> <div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>TITLE</label> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
Title
</label>
<input <input
value={searchQ} value={searchQ}
onChange={(e) => setSearchQ(e.target.value)} onChange={(e) => setSearchQ(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }} onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }}
placeholder="Search by name…" placeholder="Search by name…"
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
/> />
</div> </div>
<div> <div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>KEY</label> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
Key
</label>
<input <input
value={searchKey} value={searchKey}
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
placeholder="e.g. Am, C, F#" placeholder="e.g. Am, C, F#"
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
/> />
</div> </div>
<div> <div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>BPM MIN</label> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
BPM min
</label>
<input <input
value={searchBpmMin} value={searchBpmMin}
onChange={(e) => setSearchBpmMin(e.target.value)} onChange={(e) => setSearchBpmMin(e.target.value)}
type="number" type="number"
min={0} min={0}
placeholder="e.g. 80" placeholder="e.g. 80"
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
/> />
</div> </div>
<div> <div>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>BPM MAX</label> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
BPM max
</label>
<input <input
value={searchBpmMax} value={searchBpmMax}
onChange={(e) => setSearchBpmMax(e.target.value)} onChange={(e) => setSearchBpmMax(e.target.value)}
type="number" type="number"
min={0} min={0}
placeholder="e.g. 140" placeholder="e.g. 140"
style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }}
/> />
</div> </div>
</div> </div>
{/* Tag filter */} <div style={{ marginBottom: 10 }}>
<div> <label style={{ display: "block", color: "rgba(255,255,255,0.28)", fontSize: 10, letterSpacing: "0.6px", textTransform: "uppercase", marginBottom: 4 }}>
<label style={{ display: "block", color: "var(--text-muted)", fontSize: 10, marginBottom: 4 }}>TAGS (must have all)</label> Tags (must have all)
</label>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 6 }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 6 }}>
{searchTags.map((t) => ( {searchTags.map((t) => (
<span <span
key={t} key={t}
style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 11, padding: "2px 8px", borderRadius: 12, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 4 }} style={{
background: "rgba(61,200,120,0.08)",
color: "#4dba85",
fontSize: 11,
padding: "2px 8px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 4,
}}
> >
{t} {t}
<button <button
onClick={() => removeTag(t)} onClick={() => removeTag(t)}
style={{ background: "none", border: "none", color: "var(--teal)", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1 }} style={{ background: "none", border: "none", color: "#4dba85", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1, fontFamily: "inherit" }}
>×</button> >
×
</button>
</span> </span>
))} ))}
</div> </div>
@@ -582,11 +600,11 @@ export function BandPage() {
onChange={(e) => setSearchTagInput(e.target.value)} onChange={(e) => setSearchTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()} onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder="Add tag…" placeholder="Add tag…"
style={{ flex: 1, padding: "6px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 12 }} 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 <button
onClick={addTag} onClick={addTag}
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 10px", fontSize: 12 }} 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> </button>
@@ -595,27 +613,36 @@ export function BandPage() {
<button <button
onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }} onClick={() => { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); }}
style={{ marginTop: 12, background: "var(--accent)", border: "none", borderRadius: 6, color: "var(--accent-fg)", cursor: "pointer", padding: "7px 18px", fontSize: 13, fontWeight: 600 }} style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
}}
> >
Search Search
</button> </button>
</div> </div>
{/* Results */} {searchFetching && <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Searching</p>}
{searchFetching && <p style={{ color: "var(--text-muted)", fontSize: 13 }}>Searching</p>}
{!searchFetching && searchDirty && ( {!searchFetching && searchDirty && (
<div style={{ display: "grid", gap: 8 }}> <div style={{ display: "grid", gap: 6 }}>
{searchResults?.map((song) => ( {searchResults?.map((song) => (
<Link <Link
key={song.id} key={song.id}
to={`/bands/${bandId}/songs/${song.id}`} to={`/bands/${bandId}/songs/${song.id}`}
style={{ style={{
background: "var(--bg-subtle)", background: "rgba(255,255,255,0.02)",
border: "1px solid var(--border-subtle)", border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8, borderRadius: 8,
padding: "14px 18px", padding: "13px 16px",
textDecoration: "none", textDecoration: "none",
color: "var(--text)", color: "#eeeef2",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
@@ -623,36 +650,35 @@ export function BandPage() {
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div> <div style={{ fontWeight: 500, marginBottom: 4, color: "#d8d8e4" }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => ( {song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span> <span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))} ))}
{song.global_key && ( {song.global_key && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span> <span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_key}</span>
)} )}
{song.global_bpm && ( {song.global_bpm && (
<span style={{ background: "var(--bg-subtle)", color: "var(--text-muted)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span> <span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{song.global_bpm.toFixed(0)} BPM</span>
)} )}
</div> </div>
</div> </div>
<div style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}> <div style={{ color: "rgba(255,255,255,0.28)", fontSize: 12, whiteSpace: "nowrap", flexShrink: 0 }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span> <span style={{ background: "rgba(255,255,255,0.05)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""} {song.version_count} version{song.version_count !== 1 ? "s" : ""}
</div> </div>
</Link> </Link>
))} ))}
{searchResults?.length === 0 && ( {searchResults?.length === 0 && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No songs match your filters.</p> <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No songs match your filters.</p>
)} )}
</div> </div>
)} )}
{!searchDirty && ( {!searchDirty && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>Enter filters above and hit Search.</p> <p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Enter filters above and hit Search.</p>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
); );
} }

View File

@@ -0,0 +1,151 @@
# BandSettingsPage — Test Cases
Feature branch: `feature/main-view-refactor`
---
## 1. BandPage cleanliness
**TC-01** — BandPage renders no member list
Navigate to `/bands/:bandId`. Assert that no member name, email, or role badge is rendered.
**TC-02** — BandPage renders no invite button
Navigate to `/bands/:bandId`. Assert that "+ Invite" is absent.
**TC-03** — BandPage renders no NC folder widget
Navigate to `/bands/:bandId`. Assert that "NEXTCLOUD SCAN FOLDER" / "SCAN PATH" label is absent.
**TC-04** — BandPage still shows sessions
Navigate to `/bands/:bandId`. Assert that dated session rows are rendered (or empty-state message if no sessions).
**TC-05** — BandPage still shows Scan Nextcloud button
Navigate to `/bands/:bandId`. Assert "⟳ Scan Nextcloud" button is present.
**TC-06** — BandPage still shows + New Song button
Navigate to `/bands/:bandId`. Assert "+ New Song" button is present.
**TC-07** — BandPage search tab remains functional
Click "Search" tab, enter a query, click Search. Assert results render or empty state shown.
---
## 2. Navigation — sidebar
**TC-08** — Band settings nav items appear when band is active
Log in, select any band. Assert sidebar contains "Members", "Storage", "Band Settings" nav items under a "Band Settings" section label.
**TC-09** — Band settings nav items absent when no band active
Navigate to `/` (no band selected). Assert sidebar does NOT show "Members", "Storage", "Band Settings" items.
**TC-10** — Members nav item highlights correctly
Navigate to `/bands/:bandId/settings/members`. Assert "Members" nav item has amber active style; "Storage" and "Band Settings" do not.
**TC-11** — Storage nav item highlights correctly
Navigate to `/bands/:bandId/settings/storage`. Assert "Storage" nav item is active.
**TC-12** — Band Settings nav item highlights correctly
Navigate to `/bands/:bandId/settings/band`. Assert "Band Settings" nav item is active.
**TC-13** — Switching bands from band switcher while on settings stays on the same panel type
On `/bands/A/settings/storage`, switch to band B. Assert navigation goes to `/bands/B` (library) — band switcher navigates to library, which is correct. Band settings panel is band-specific.
---
## 3. Routing
**TC-14** — Base settings URL redirects to members panel
Navigate directly to `/bands/:bandId/settings`. Assert browser URL redirects to `/bands/:bandId/settings/members` without a visible flash.
**TC-15** — Direct URL navigation to storage panel works
Navigate directly to `/bands/:bandId/settings/storage`. Assert Storage panel content is rendered.
**TC-16** — Direct URL navigation to band panel works
Navigate directly to `/bands/:bandId/settings/band`. Assert Band Settings panel content is rendered.
**TC-17** — Unknown panel falls back to members
Navigate to `/bands/:bandId/settings/unknown-panel`. Assert Members panel is rendered (fallback in `activePanel` logic).
---
## 4. Members panel — access control
**TC-18** — Admin sees + Invite button
Log in as admin, navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is present.
**TC-19** — Non-admin does not see + Invite button
Log in as member (non-admin), navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is absent.
**TC-20** — Admin sees Remove button on non-admin members
Log in as admin. Assert "Remove" button appears next to member-role users.
**TC-21** — Non-admin does not see Remove button
Log in as member. Assert no "Remove" button appears for any member.
**TC-22** — Admin does not see Remove button for other admins
Log in as admin. Assert "Remove" button is absent next to rows where role is "admin".
**TC-23** — Pending Invites section only visible to admins
Log in as member. Assert "Pending Invites" heading is absent.
---
## 5. Members panel — functionality
**TC-24** — Generate invite creates a link and copies to clipboard
As admin, click "+ Invite". Assert an invite URL (`/invite/<token>`) appears in the UI and `navigator.clipboard.writeText` was called with it.
**TC-25** — Dismiss hides the invite link banner
After generating an invite, click "Dismiss". Assert the invite link banner disappears.
**TC-26** — Remove member removes from list
As admin, click "Remove" on a member-role row. Mock the DELETE endpoint to 200. Assert the members query is invalidated and the member disappears.
**TC-27** — Revoke invite removes from pending list
As admin, click "Revoke" on a pending invite. Mock the DELETE endpoint. Assert the invites query is invalidated.
**TC-28** — Copy invite link writes to clipboard
In the pending invites list, click "Copy" on an invite row. Assert `navigator.clipboard.writeText` was called with the correct URL.
---
## 6. Storage panel — access control and functionality
**TC-29** — Admin sees Edit button on NC folder path
Log in as admin, navigate to storage panel. Assert "Edit" button is visible next to the scan path.
**TC-30** — Non-admin does not see Edit button
Log in as member, navigate to storage panel. Assert "Edit" button is absent.
**TC-31** — Editing NC folder path and saving updates the band
As admin, click Edit, change the path, click Save. Mock PATCH `/bands/:bandId` to 200. Assert band query is invalidated and edit form closes.
**TC-32** — Cancel edit closes form without saving
As admin, click Edit, change the path, click Cancel. Assert the form disappears and PATCH was not called.
**TC-33** — Default path shown when nc_folder_path is null
When `band.nc_folder_path` is null, assert the displayed path is `bands/<slug>/`.
---
## 7. Band settings panel — access control and functionality
**TC-34** — Admin sees Save changes button
Log in as admin, navigate to band panel. Assert "Save changes" button is present.
**TC-35** — Non-admin does not see Save button, sees info text
Log in as member, navigate to band panel. Assert "Save changes" absent and "Only admins can edit band settings." is shown.
**TC-36** — Name field is disabled for non-admins
Log in as member. Assert the band name input has the `disabled` attribute.
**TC-37** — Saving band name and tags calls PATCH
As admin, change band name to "New Name", click Save. Assert PATCH `/bands/:bandId` called with `{ name: "New Name", genre_tags: [...] }`.
**TC-38** — Adding a genre tag updates the tag list
Type "punk" in the tag input, press Enter. Assert "punk" pill appears in the tag list.
**TC-39** — Removing a genre tag removes its pill
Click the × on a genre tag pill. Assert the pill disappears from the list.
**TC-40** — Delete band button disabled for non-admins
Log in as member. Assert the "Delete band" button has the `disabled` attribute.

View File

@@ -0,0 +1,320 @@
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

@@ -0,0 +1,867 @@
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 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>
</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>
);
}

34
web/src/test/helpers.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { render } from "@testing-library/react";
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0 },
mutations: { retry: false },
},
});
}
interface RenderOptions {
path?: string;
route?: string;
}
export function renderWithProviders(
ui: React.ReactElement,
{ path = "/", route = "/" }: RenderOptions = {}
) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path={path} element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}