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:
280
README.md
Normal file
280
README.md
Normal 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: **~60–90 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.
|
||||||
41
Taskfile.yml
41
Taskfile.yml
@@ -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
138
web/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
97
web/src/pages/BandPage.test.tsx
Normal file
97
web/src/pages/BandPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
151
web/src/pages/BandSettingsPage.test.md
Normal file
151
web/src/pages/BandSettingsPage.test.md
Normal 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.
|
||||||
320
web/src/pages/BandSettingsPage.test.tsx
Normal file
320
web/src/pages/BandSettingsPage.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
867
web/src/pages/BandSettingsPage.tsx
Normal file
867
web/src/pages/BandSettingsPage.tsx
Normal 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
34
web/src/test/helpers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user