4 Commits

Author SHA1 Message Date
Mistral Vibe
a22348d282 feat(mobile): add band creation to mobile header
- Extract CreateBandModal into shared component (was desktop-only in TopBandBar)
- Redesign TopBar with app branding on left and full band picker on right
- Add "New band" entry to mobile band picker dropdown
- Mobile now supports two-step band creation (storage setup + band details)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:04:12 +02:00
Mistral Vibe
4bab0a76f7 Build update 2026-04-10 12:23:27 +02:00
Mistral Vibe
5bb3f9c1f7 up 2026-04-10 12:09:13 +02:00
Mistral Vibe
7e7fd8c8f0 adding prod compose 2026-04-10 11:40:55 +02:00
8 changed files with 630 additions and 446 deletions

View File

@@ -228,23 +228,14 @@ class AudioVersion(Base):
version_number: Mapped[int] = mapped_column(Integer, nullable=False) version_number: Mapped[int] = mapped_column(Integer, nullable=False)
label: Mapped[str | None] = mapped_column(String(255)) label: Mapped[str | None] = mapped_column(String(255))
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False) nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
<<<<<<< HEAD
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
=======
nc_file_etag: Mapped[str | None] = mapped_column(String(255)) nc_file_etag: Mapped[str | None] = mapped_column(String(255))
cdn_hls_base: Mapped[str | None] = mapped_column(Text) cdn_hls_base: Mapped[str | None] = mapped_column(Text)
waveform_url: Mapped[str | None] = mapped_column(Text) waveform_url: Mapped[str | None] = mapped_column(Text)
waveform_peaks: Mapped[list | None] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[list | None] = mapped_column(JSONB)
duration_ms: Mapped[int | None] = mapped_column(Integer) duration_ms: Mapped[int | None] = mapped_column(Integer)
format: Mapped[str | None] = mapped_column(String(10)) format: Mapped[str | None] = mapped_column(String(10))
file_size_bytes: Mapped[int | None] = mapped_column(BigInteger) file_size_bytes: Mapped[int | None] = mapped_column(BigInteger)
>>>>>>> feature/pipeline-fix
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
uploaded_by: Mapped[uuid.UUID | None] = mapped_column( uploaded_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")

View File

@@ -11,11 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import AudioVersion, BandMember, Member from rehearsalhub.db.models import AudioVersion, BandMember, Member
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService

View File

@@ -92,10 +92,9 @@ async def test_waveform_404_when_no_peaks_in_db(mock_session):
with ( with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)), return_value=(version, song)),pytest.raises(HTTPException) as exc_info
): ):
with pytest.raises(HTTPException) as exc_info: await get_waveform(version_id=version.id, session=mock_session, current_member=member)
await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
@@ -113,8 +112,8 @@ async def test_waveform_mini_404_when_no_mini_peaks(mock_session):
with ( with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)), return_value=(version, song)),
pytest.raises(HTTPException) as exc_info,
): ):
with pytest.raises(HTTPException) as exc_info: await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404

132
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,132 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
POSTGRES_USER: ${POSTGRES_USER:-rh_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- rh_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub} || exit 1"]
interval: 15s
timeout: 10s
retries: 30
start_period: 45s
restart: unless-stopped
command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"]
redis:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
networks:
- rh_net
healthcheck:
test: ["CMD-SHELL", "redis-cli ping || exit 1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 25s
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
api:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/api:0.1.0
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
DOMAIN: ${DOMAIN:-localhost}
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
interval: 20s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
audio-worker:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/worker:0.1.0
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
ANALYSIS_VERSION: "1.0.0"
volumes:
- audio_tmp:/tmp/audio
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_started
restart: unless-stopped
nc-watcher:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/watcher:0.1.0
environment:
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
API_URL: http://api:8000
REDIS_URL: redis://redis:6379/0
POLL_INTERVAL: "30"
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_started
restart: unless-stopped
web:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/web:0.1.0
ports:
- "8080:80"
networks:
- frontend
- rh_net
depends_on:
- api
restart: unless-stopped
networks:
frontend:
name: proxy
external: true
rh_net:
volumes:
pg_data:
redis_data:
audio_tmp:

View File

@@ -41,7 +41,7 @@ services:
build: build:
context: ./api context: ./api
target: production target: production
image: rehearsalhub/api:latest image: rehearshalhub/api:latest
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
@@ -74,7 +74,7 @@ services:
build: build:
context: ./worker context: ./worker
target: production target: production
image: rehearsalhub/audio-worker:latest image: rehearshalhub/audio-worker:latest
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
@@ -99,7 +99,7 @@ services:
build: build:
context: ./watcher context: ./watcher
target: production target: production
image: rehearsalhub/nc-watcher:latest image: rehearshalhub/nc-watcher:latest
environment: environment:
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service} NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
@@ -122,7 +122,7 @@ services:
build: build:
context: ./web context: ./web
target: production target: production
image: rehearsalhub/web:latest image: rehearshalhub/web:latest
ports: ports:
- "8080:80" - "8080:80"
networks: networks:

View File

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

View File

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

View File

@@ -3,11 +3,13 @@ import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands"; import { listBands } from "../api/bands";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import { CreateBandModal } from "./CreateBandModal";
export function TopBar() { export function TopBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands }); const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
@@ -30,127 +32,186 @@ export function TopBar() {
}, [dropdownOpen]); }, [dropdownOpen]);
return ( return (
<header <>
style={{ {showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
position: "fixed",
top: 0, <header
left: 0, style={{
right: 0, position: "fixed",
height: 50, top: 0,
background: "#0b0b0e", left: 0,
borderBottom: "1px solid rgba(255,255,255,0.06)", right: 0,
zIndex: 1000, height: 50,
display: "flex", background: "#0b0b0e",
justifyContent: "flex-end", borderBottom: "1px solid rgba(255,255,255,0.06)",
padding: "0 16px", zIndex: 1000,
alignItems: "center", display: "flex",
}} alignItems: "center",
> justifyContent: "space-between",
<div ref={dropdownRef} style={{ position: "relative" }}> padding: "0 16px",
<button }}
onClick={() => setDropdownOpen((o) => !o)} >
style={{ {/* App name / logo */}
display: "flex", <span style={{ fontFamily: "monospace", fontSize: 13, fontWeight: 700, color: "#14b8a6", letterSpacing: "0.02em", userSelect: "none" }}>
alignItems: "center", RH
gap: 8, </span>
padding: "6px 10px",
background: "rgba(255,255,255,0.05)", {/* Band picker */}
border: "1px solid rgba(255,255,255,0.07)", <div ref={dropdownRef} style={{ position: "relative" }}>
borderRadius: 8, <button
cursor: "pointer", onClick={() => setDropdownOpen((o) => !o)}
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
fontSize: 13,
}}
>
<div
style={{ style={{
width: 32,
height: 32,
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
borderRadius: "50%",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", gap: 8,
fontSize: 12, padding: "5px 10px",
fontWeight: 700, background: dropdownOpen ? "rgba(255,255,255,0.07)" : "rgba(255,255,255,0.04)",
color: "#e8a22a", border: `1px solid ${dropdownOpen ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.07)"}`,
flexShrink: 0, borderRadius: 8,
cursor: "pointer",
color: "#eeeef2",
fontFamily: "inherit",
fontSize: 13,
maxWidth: 180,
}} }}
> >
{activeBand ? getInitials(activeBand.name) : "?"} <div
</div> style={{
</button> width: 26,
height: 26,
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{activeBand ? getInitials(activeBand.name) : "?"}
</div>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 13, fontWeight: 500 }}>
{activeBand ? activeBand.name : "Select band"}
</span>
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", flexShrink: 0 }}>
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{dropdownOpen && bands && ( {dropdownOpen && (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
top: "calc(100% + 4px)", top: "calc(100% + 6px)",
right: 0, right: 0,
width: 200, minWidth: 200,
background: "#18181e", background: "#18181e",
border: "1px solid rgba(255,255,255,0.1)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, borderRadius: 10,
padding: 6, padding: 6,
zIndex: 1001, zIndex: 1001,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)", boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
}} }}
> >
{bands.map((band) => ( {bands?.map((band) => (
<button <button
key={band.id} key={band.id}
onClick={() => { onClick={() => {
navigate(`/bands/${band.id}`); navigate(`/bands/${band.id}`);
setDropdownOpen(false); setDropdownOpen(false);
}} }}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
marginBottom: 2,
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
fontSize: 13,
}}
>
<div
style={{ style={{
width: 24, width: "100%",
height: 24,
borderRadius: "50%",
background: "rgba(232,162,42,0.15)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", gap: 8,
fontSize: 10, padding: "8px 10px",
fontWeight: 700, marginBottom: 2,
color: "#e8a22a", background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
flexShrink: 0, border: "none",
borderRadius: 6,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
fontSize: 13,
}} }}
> >
{getInitials(band.name)} <div
</div> style={{
<span style={{ flex: 1, fontSize: 13 }}> width: 24,
{band.name} height: 24,
</span> borderRadius: "50%",
{band.id === activeBandId && ( background: "rgba(232,162,42,0.15)",
<span style={{ fontSize: 12, color: "#e8a22a", flexShrink: 0 }}></span> display: "flex",
)} alignItems: "center",
</button> justifyContent: "center",
))} fontSize: 10,
</div> fontWeight: 700,
)} color: "#e8a22a",
</div> flexShrink: 0,
</header> }}
>
{getInitials(band.name)}
</div>
<span style={{ flex: 1, fontSize: 13, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{band.name}
</span>
{band.id === activeBandId && (
<span style={{ fontSize: 12, color: "#e8a22a", flexShrink: 0 }}></span>
)}
</button>
))}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => {
setDropdownOpen(false);
setShowCreate(true);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
background: "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "rgba(232,233,240,0.4)",
textAlign: "left",
fontFamily: "inherit",
fontSize: 13,
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: "50%",
background: "rgba(20,184,166,0.1)",
border: "1px dashed rgba(20,184,166,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
color: "#14b8a6",
flexShrink: 0,
}}
>
+
</div>
<span style={{ color: "rgba(232,233,240,0.5)", fontSize: 13 }}>New band</span>
</button>
</div>
</div>
)}
</div>
</header>
</>
); );
} }