diff --git a/backend/app/config.py b/backend/app/config.py index 5ccdf02..88b630b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,6 +14,20 @@ TRANSCRIPT_DIR = DATA_DIR / "transcriptions" NOTES_DIR = DATA_DIR / "notes" DB_PATH = DATA_DIR / "app.db" +CAMPAIGNS_DIR = DATA_DIR / "campaigns" + +def campaign_dir(campaign_id: str) -> Path: + return CAMPAIGNS_DIR / campaign_id + +def campaign_audio_dir(campaign_id: str) -> Path: + return campaign_dir(campaign_id) / "audio" + +def campaign_transcript_dir(campaign_id: str) -> Path: + return campaign_dir(campaign_id) / "transcriptions" + +def campaign_notes_dir(campaign_id: str) -> Path: + return campaign_dir(campaign_id) / "notes" + for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR): d.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/database.py b/backend/app/database.py index 8ed2d82..a644775 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -86,6 +86,26 @@ def init_db(): updated_at REAL NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS campaigns ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL + ) + """) + try: + conn.execute("ALTER TABLE sessions ADD COLUMN campaign_id TEXT REFERENCES campaigns(id) ON DELETE SET NULL") + except Exception: + pass + default = conn.execute("SELECT id FROM campaigns WHERE id = 'default'").fetchone() + if not default: + conn.execute( + "INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, '', ?)", + ("default", "Default Campaign", now()), + ) + conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL") + # seed defaults if empty existing = {r["key"] for r in conn.execute("SELECT key FROM settings")} for k, v in config.DEFAULT_SETTINGS.items(): @@ -140,3 +160,47 @@ def update_job(job_id: str, **fields): def get_job(job_id: str) -> dict | None: row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() return dict(row) if row else None + +def create_campaign(name: str, description: str = "") -> dict: + campaign_id = new_id() + with tx() as conn: + conn.execute( + "INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)", + (campaign_id, name, description, now()), + ) + from .config import campaign_audio_dir, campaign_transcript_dir, campaign_notes_dir + campaign_audio_dir(campaign_id).mkdir(parents=True, exist_ok=True) + campaign_transcript_dir(campaign_id).mkdir(parents=True, exist_ok=True) + campaign_notes_dir(campaign_id).mkdir(parents=True, exist_ok=True) + return get_campaign(campaign_id) + +def list_campaigns() -> list[dict]: + rows = get_conn().execute( + "SELECT c.*, (SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count " + "FROM campaigns c ORDER BY c.created_at DESC" + ).fetchall() + return [dict(r) for r in rows] + +def get_campaign(campaign_id: str) -> dict | None: + row = get_conn().execute( + "SELECT c.*, (SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count " + "FROM campaigns c WHERE c.id = ?", (campaign_id,) + ).fetchone() + return dict(row) if row else None + +def update_campaign(campaign_id: str, name: str | None = None, description: str | None = None) -> dict | None: + fields = {} + if name is not None: fields["name"] = name + if description is not None: fields["description"] = description + if not fields: + return get_campaign(campaign_id) + with tx() as conn: + cols = ", ".join(f"{k} = ?" for k in fields) + conn.execute(f"UPDATE campaigns SET {cols} WHERE id = ?", (*fields.values(), campaign_id)) + return get_campaign(campaign_id) + +def delete_campaign(campaign_id: str) -> bool: + with tx() as conn: + conn.execute("UPDATE sessions SET campaign_id = NULL WHERE campaign_id = ?", (campaign_id,)) + conn.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,)) + return True diff --git a/backend/app/jobs.py b/backend/app/jobs.py index 65b0ae5..b3a8a0b 100644 --- a/backend/app/jobs.py +++ b/backend/app/jobs.py @@ -1,6 +1,11 @@ """ Minimal background job runner. + +GPU jobs use a shared counter so the Whisper model stays cached between +back-to-back transcriptions and is only released when no more GPU work +is queued. """ +import threading import traceback from concurrent.futures import ThreadPoolExecutor @@ -9,10 +14,23 @@ from .errors import PipelineError from .logging_config import get_logger log = get_logger(__name__) -_executor = ThreadPoolExecutor(max_workers=2) # transcription is GPU-bound anyway; no benefit to more workers +_executor = ThreadPoolExecutor(max_workers=1) +_gpu_jobs_queued = 0 +_gpu_lock = threading.Lock() -def submit(job_id: str, fn, *args, **kwargs): +def _unload_gpu(): + """Release the cached Whisper model from GPU memory.""" + from .pipeline.transcribe import unload_models + unload_models() + + +def submit(job_id: str, fn, *args, requires_gpu=False, **kwargs): + if requires_gpu: + with _gpu_lock: + global _gpu_jobs_queued + _gpu_jobs_queued += 1 + def _run(): log.info("Job %s starting", job_id) db.update_job(job_id, status="running", progress="Starting...") @@ -37,5 +55,13 @@ def submit(job_id: str, fn, *args, **kwargs): error=f"Unexpected error: {e}", error_stage="unknown", error_detail=traceback.format_exc(), ) + finally: + if requires_gpu: + with _gpu_lock: + global _gpu_jobs_queued + _gpu_jobs_queued -= 1 + if _gpu_jobs_queued <= 0: + log.info("No more GPU jobs queued — unloading Whisper model") + _unload_gpu() _executor.submit(_run) diff --git a/backend/app/main.py b/backend/app/main.py index 3558f2a..0fc1040 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +from pathlib import Path + from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse @@ -14,6 +16,7 @@ from .routers import jobs as jobs_router from .routers import models as models_router from .routers import diagnostics as diagnostics_router from .routers import files as files_router +from .routers import campaigns as campaigns_router setup_logging() log = get_logger(__name__) @@ -54,11 +57,15 @@ app.include_router(jobs_router.router) app.include_router(models_router.router) app.include_router(diagnostics_router.router) app.include_router(files_router.router) +app.include_router(campaigns_router.router) @app.get("/api/sessions/{session_id}/audio") def get_audio(session_id: str): - audio_path = config.AUDIO_DIR / f"{session_id}.wav" + row = db.get_conn().execute("SELECT audio_path FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not row or not row["audio_path"]: + raise HTTPException(404, "Session not found or audio not available") + audio_path = Path(row["audio_path"]) if not audio_path.exists(): raise HTTPException(404, "Audio not available for this session") return FileResponse(audio_path, media_type="audio/wav") diff --git a/backend/app/pipeline/transcribe.py b/backend/app/pipeline/transcribe.py index fa24d71..61d56bd 100644 --- a/backend/app/pipeline/transcribe.py +++ b/backend/app/pipeline/transcribe.py @@ -13,6 +13,53 @@ from ..logging_config import get_logger log = get_logger(__name__) +# Module-level model cache — kept alive across jobs so back-to-back +# transcriptions don't reload from disk each time. +_whisper_model = None +_whisper_config = {} # {"model_size": str, "compute_type": str, "device": str} + + +def _get_model(model_size: str, compute_type: str, device: str): + global _whisper_model, _whisper_config + if ( + _whisper_model is not None + and _whisper_config.get("model_size") == model_size + and _whisper_config.get("compute_type") == compute_type + and _whisper_config.get("device") == device + ): + log.info("Reusing cached Whisper model (%s, %s, %s)", model_size, compute_type, device) + return _whisper_model + log.info("Loading Whisper model (%s, %s, %s)", model_size, compute_type, device) + _unload_cached() + _whisper_model = whisperx.load_model(model_size, device, compute_type=compute_type) + _whisper_config = {"model_size": model_size, "compute_type": compute_type, "device": device} + return _whisper_model + + +def _unload_cached(): + global _whisper_model, _whisper_config + if _whisper_model is not None: + log.info("Unloading cached Whisper model") + del _whisper_model + _whisper_model = None + _whisper_config = {} + gc.collect() + if torch.cuda.is_available(): + torch.cuda.synchronize() + torch.cuda.empty_cache() + torch.cuda.reset_peak_memory_stats() + + +def unload_models(): + """Release all GPU memory held by transcription models. + Called by the job runner when no more GPU jobs are queued.""" + _unload_cached() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.synchronize() + torch.cuda.empty_cache() + torch.cuda.reset_peak_memory_stats() + def transcribe_and_diarize( audio_path: Path, @@ -33,7 +80,7 @@ def transcribe_and_diarize( report("Loading transcription model...") try: - model = whisperx.load_model(model_size, device, compute_type=compute_type) + model = _get_model(model_size, compute_type, device) except RuntimeError as e: if "out of memory" in str(e).lower(): raise TranscriptionError( @@ -59,22 +106,21 @@ def transcribe_and_diarize( raise TranscriptionError(f"Transcription failed: {e}", cause=e) except Exception as e: raise TranscriptionError(f"Transcription failed: {e}", cause=e) - finally: - del model - gc.collect() - if device == "cuda": - torch.cuda.empty_cache() try: report("Aligning...") align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device) result = whisperx.align(result["segments"], align_model, metadata, audio, device, return_char_alignments=False) - del align_model + except Exception as e: + raise TranscriptionError(f"Alignment step failed: {e}", cause=e) + finally: + try: + del align_model + except NameError: + pass gc.collect() if device == "cuda": torch.cuda.empty_cache() - except Exception as e: - raise TranscriptionError(f"Alignment step failed: {e}", cause=e) if hf_token: try: @@ -91,6 +137,14 @@ def transcribe_and_diarize( f"your HF token belongs to.\n{msg}", cause=e ) raise DiarizationError(f"Diarization failed: {msg}", cause=e) + finally: + try: + del diarize_model + except NameError: + pass + gc.collect() + if device == "cuda": + torch.cuda.empty_cache() else: report("No HF token set - skipping speaker diarization (all speech will be unattributed)") diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py new file mode 100644 index 0000000..5ebb533 --- /dev/null +++ b/backend/app/routers/campaigns.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, HTTPException + +from .. import database as db + +router = APIRouter(prefix="/api/campaigns", tags=["campaigns"]) + + +@router.get("") +def list_campaigns(): + return db.list_campaigns() + + +@router.post("") +def create_campaign(body: dict): + name = body.get("name", "").strip() + if not name: + raise HTTPException(400, "Name is required") + desc = body.get("description", "").strip() + return db.create_campaign(name, desc) + + +@router.get("/{campaign_id}") +def get_campaign(campaign_id: str): + campaign = db.get_campaign(campaign_id) + if not campaign: + raise HTTPException(404, "Campaign not found") + return campaign + + +@router.put("/{campaign_id}") +def update_campaign(campaign_id: str, body: dict): + if not db.get_campaign(campaign_id): + raise HTTPException(404, "Campaign not found") + updated = db.update_campaign( + campaign_id, + name=body.get("name"), + description=body.get("description"), + ) + return updated + + +@router.delete("/{campaign_id}") +def delete_campaign(campaign_id: str): + if not db.get_campaign(campaign_id): + raise HTTPException(404, "Campaign not found") + db.delete_campaign(campaign_id) + return {"ok": True} diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index f7162f1..f871025 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -1,4 +1,3 @@ -import shutil from pathlib import Path from fastapi import APIRouter, HTTPException, Request @@ -11,25 +10,14 @@ router = APIRouter(prefix="/api/files", tags=["files"]) DATA = config.DATA_DIR.resolve() -def _safe_path(path_str: str) -> Path: - resolved = (DATA / path_str).resolve() - if not str(resolved).startswith(str(DATA)): +def _safe_path(path_str: str, campaign_id: str | None = None) -> Path: + root = (config.campaign_dir(campaign_id) if campaign_id else DATA).resolve() + resolved = (root / path_str).resolve() + if not str(resolved).startswith(str(root)): raise HTTPException(400, "Path traversal denied") return resolved -def _auto_dest(source: Path) -> Path: - parent = source.parent - stem = source.stem - suffix = source.suffix - candidate = parent / f"{stem}_copy{suffix}" - n = 2 - while candidate.exists(): - candidate = parent / f"{stem}_copy_{n}{suffix}" - n += 1 - return candidate - - def _auto_name(parent: Path, name: str) -> Path: p = parent / name if not p.exists(): @@ -53,22 +41,29 @@ def _enrich(entries, conn): @router.get("/browse") -def browse(path: str = ""): +def browse(path: str = "", campaign_id: str = None): clean = path.strip("/") entries = [] conn = db.get_conn() + base = config.campaign_dir(campaign_id) if campaign_id else DATA - # Root level — show the three directories if not clean: for name in sorted(["audio", "transcriptions", "notes"]): entries.append({"name": name, "type": "dir", "path": name}) return {"entries": entries, "current_path": "", "parent_path": None} - # Virtual "notes" directory — list from DB if clean == "notes": - rows = conn.execute( - "SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC" - ).fetchall() + if campaign_id: + rows = conn.execute( + """SELECT n.session_id, n.dm_notes, n.player_recap, n.generated_at + FROM notes n JOIN sessions s ON n.session_id = s.id + WHERE s.campaign_id = ? ORDER BY n.generated_at DESC""", + (campaign_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC" + ).fetchall() for r in rows: for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])): if not text: @@ -85,15 +80,16 @@ def browse(path: str = ""): _enrich(entries, conn) return {"entries": entries, "current_path": "notes", "parent_path": ""} - # Real directory on disk - resolved = DATA / clean + resolved = (base / clean).resolve() + if not str(resolved).startswith(str(base.resolve())): + raise HTTPException(400, "Path traversal denied") if not resolved.exists() or not resolved.is_dir(): raise HTTPException(404, "Directory not found") for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): if f.name.startswith("tmp_"): continue - rel = str(f.relative_to(DATA)) + rel = str(f.relative_to(base)) entry = {"name": f.name, "type": "dir" if f.is_dir() else "file", "path": rel} if not f.is_dir(): entry["size"] = f.stat().st_size @@ -104,7 +100,7 @@ def browse(path: str = ""): parts = clean.split("/") parent = "/".join(parts[:-1]) if len(parts) > 1 else "" - return {"entries": entries, "current_path": clean, "parent_path": parent or None} + return {"entries": entries, "current_path": clean, "parent_path": parent} _MIME_MAP: dict[str, str] = { @@ -120,8 +116,7 @@ _MIME_MAP: dict[str, str] = { @router.get("/view") -def view_file(path: str): - # Virtual notes file — serve from DB +def view_file(path: str, campaign_id: str = None): if path.startswith("notes/"): parts = Path(path).stem.split("_") session_id = parts[0] @@ -133,8 +128,7 @@ def view_file(path: str): text = row[kind] if kind in ("dm_notes", "player_recap") else (row["player_recap"] or row["dm_notes"] or "") return Response(content=text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": "inline"}) - # Real file on disk - file_path = _safe_path(path) + file_path = _safe_path(path, campaign_id) if not file_path.exists(): raise HTTPException(404, "File not found") @@ -143,8 +137,8 @@ def view_file(path: str): @router.get("/download") -def download_file(path: str): - file_path = _safe_path(path) +def download_file(path: str, campaign_id: str = None): + file_path = _safe_path(path, campaign_id) if not file_path.exists(): raise HTTPException(404, "File not found") return FileResponse(file_path, filename=file_path.name) @@ -157,23 +151,26 @@ async def upload_file(request: Request): if not file_field or not hasattr(file_field, "filename") or not file_field.filename: raise HTTPException(400, "No file provided") dest_dir_str = form.get("dir", "audio") - dest_dir = _safe_path(dest_dir_str) + campaign_id = form.get("campaign_id") + dest_dir = _safe_path(dest_dir_str, campaign_id) dest_dir.mkdir(parents=True, exist_ok=True) dest = _auto_name(dest_dir, file_field.filename) with open(dest, "wb") as f: content = await file_field.read() f.write(content) - return {"path": str(dest.relative_to(DATA))} + root = config.campaign_dir(campaign_id) if campaign_id else DATA + return {"path": str(dest.relative_to(root))} @router.delete("") def delete_files(body: dict): paths = body.get("paths", []) + campaign_id = body.get("campaign_id") deleted = [] errors = [] for p in paths: try: - fp = _safe_path(p) + fp = _safe_path(p, campaign_id) if fp.exists(): fp.unlink() deleted.append(p) @@ -195,30 +192,3 @@ def delete_files(body: dict): except Exception as e: errors.append({"path": p, "error": str(e)}) return {"deleted": deleted, "errors": errors} - - -@router.post("/copy") -def copy_file(body: dict): - source_str = body.get("source") - dest_dir_str = body.get("dest_dir") - if not source_str: - raise HTTPException(400, "source is required") - source = _safe_path(source_str) - if not source.exists(): - raise HTTPException(404, "Source file not found") - - if dest_dir_str: - dest_dir = _safe_path(dest_dir_str) - dest_dir.mkdir(parents=True, exist_ok=True) - dest = _auto_name(dest_dir, source.name) - else: - dest = _auto_dest(source) - - if dest.exists(): - raise HTTPException(409, f"Destination already exists: {dest.name}") - - shutil.copy2(source, dest) - return { - "source": str(source.relative_to(DATA)), - "dest": str(dest.relative_to(DATA)), - } diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index bd58fce..ebcc113 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -38,8 +38,13 @@ def _row_to_dict(row) -> dict: @router.get("") -def list_sessions(): - rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall() +def list_sessions(campaign_id: str = None): + if campaign_id: + rows = db.get_conn().execute( + "SELECT * FROM sessions WHERE campaign_id = ? ORDER BY created_at DESC", (campaign_id,) + ).fetchall() + else: + rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall() sessions = [] for r in rows: s = _row_to_dict(r) @@ -108,24 +113,38 @@ def _run_transcription(session_id: str, video_path: Path, audio_path: Path, tran ) job_id = db.create_job(session_id, "transcribe") - jobs.submit(job_id, run) + jobs.submit(job_id, run, requires_gpu=True) return job_id @router.post("") -async def create_session(name: str = Form(...), file: UploadFile = File(None), upload_path: str = Form(None)): +async def create_session( + name: str = Form(...), + file: UploadFile = File(None), + upload_path: str = Form(None), + campaign_id: str = Form(None), +): session_id = db.new_id() + if campaign_id: + upload_dir = config.campaign_dir(campaign_id) + audio_dir = config.campaign_audio_dir(campaign_id) + transcript_dir = config.campaign_transcript_dir(campaign_id) + else: + upload_dir = config.UPLOAD_DIR + audio_dir = config.AUDIO_DIR + transcript_dir = config.TRANSCRIPT_DIR + if upload_path: src = Path(upload_path) if not src.exists(): raise HTTPException(400, f"Uploaded file not found at {upload_path}") safe_filename = os.path.basename(src) - video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}" + video_path = upload_dir / f"{session_id}_{safe_filename}" shutil.move(str(src), str(video_path)) elif file: safe_filename = os.path.basename(file.filename or f"{session_id}") - video_path = config.UPLOAD_DIR / f"{session_id}_{safe_filename}" + video_path = upload_dir / f"{session_id}_{safe_filename}" with open(video_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) else: @@ -133,13 +152,13 @@ async def create_session(name: str = Form(...), file: UploadFile = File(None), u with db.tx() as conn: conn.execute( - "INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) " - "VALUES (?, ?, ?, ?, 'uploaded', ?)", - (session_id, name, safe_filename, str(video_path), db.now()), + "INSERT INTO sessions (id, name, original_filename, video_path, campaign_id, status, created_at) " + "VALUES (?, ?, ?, ?, ?, 'uploaded', ?)", + (session_id, name, safe_filename, str(video_path), campaign_id, db.now()), ) - audio_path = config.AUDIO_DIR / f"{session_id}.wav" - transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" + audio_path = audio_dir / f"{session_id}.wav" + transcript_path = transcript_dir / f"{session_id}.json" job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) return {"session_id": session_id, "job_id": job_id} @@ -153,8 +172,13 @@ def retry_transcription(session_id: str): raise HTTPException(404, "Session not found") session = _row_to_dict(row) video_path = Path(session["video_path"]) - audio_path = config.AUDIO_DIR / f"{session_id}.wav" - transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" + campaign_id = session.get("campaign_id") + if campaign_id: + audio_path = config.campaign_audio_dir(campaign_id) / f"{session_id}.wav" + transcript_path = config.campaign_transcript_dir(campaign_id) / f"{session_id}.json" + else: + audio_path = config.AUDIO_DIR / f"{session_id}.wav" + transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json" job_id = _run_transcription(session_id, video_path, audio_path, transcript_path) return {"job_id": job_id} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6ada8e..f774286 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import Notes from './pages/Notes'; import Files from './pages/Files'; import Settings from './pages/Settings'; import Diagnostics from './pages/Diagnostics'; +import Campaigns from './pages/Campaigns'; +import Layout from './components/Layout'; import { api } from './api'; function App() { @@ -41,8 +43,9 @@ function App() { {/* If configured, serve the main sessions workspace */} {isConfigured ? ( - <> + }> } /> + } /> } /> } /> } /> @@ -50,7 +53,7 @@ function App() { } /> } /> } /> - + ) : ( } /> )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d7ea2f8..fbc3709 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -36,6 +36,14 @@ export interface Job { updated_at: number; } +export interface Campaign { + id: string; + name: string; + description: string; + created_at: number; + session_count: number; +} + export type DeleteStrategy = 'all' | 'artifacts_only' | 'none'; export const jobApi = { @@ -55,6 +63,41 @@ export const jobApi = { }, }; +export const campaignApi = { + list: async (): Promise => { + const res = await fetch(`${BASE_URL}/campaigns`); + if (!res.ok) throw new Error('Failed to list campaigns'); + return res.json(); + }, + create: async (name: string, description?: string): Promise => { + const res = await fetch(`${BASE_URL}/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }), + }); + if (!res.ok) throw new Error('Failed to create campaign'); + return res.json(); + }, + get: async (id: string): Promise => { + const res = await fetch(`${BASE_URL}/campaigns/${id}`); + if (!res.ok) throw new Error('Failed to get campaign'); + return res.json(); + }, + update: async (id: string, body: Partial>): Promise => { + const res = await fetch(`${BASE_URL}/campaigns/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Failed to update campaign'); + return res.json(); + }, + delete: async (id: string): Promise => { + const res = await fetch(`${BASE_URL}/campaigns/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete campaign'); + }, +}; + export const api = { // Settings getSettings: async (): Promise => { @@ -75,17 +118,19 @@ export const api = { }, // Sessions - listSessions: async (): Promise => { - const res = await fetch(`${BASE_URL}/sessions`); + listSessions: async (campaignId?: string): Promise => { + const qs = campaignId ? `?campaign_id=${encodeURIComponent(campaignId)}` : ''; + const res = await fetch(`${BASE_URL}/sessions${qs}`); return res.json(); }, - createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => { + createSession: async (name: string, file: File, onProgress?: (pct: number) => void, campaignId?: string): Promise<{ session_id: string; job_id: string }> => { const CHUNK_THRESHOLD = 90 * 1024 * 1024; if (file.size > CHUNK_THRESHOLD) { const result = await api.uploadFileInChunks(file, onProgress || (() => {})); const formData = new FormData(); formData.append('name', name); formData.append('upload_path', result.filepath); + if (campaignId) formData.append('campaign_id', campaignId); const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); if (!res.ok) throw new Error('Failed to create session'); return res.json(); @@ -93,6 +138,7 @@ export const api = { const formData = new FormData(); formData.append('name', name); formData.append('file', file); + if (campaignId) formData.append('campaign_id', campaignId); const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData }); if (!res.ok) throw new Error('Failed to create session'); return res.json(); @@ -195,34 +241,37 @@ export const api = { }, // Files - browseFiles: async (path?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => { - const qs = path ? `?path=${encodeURIComponent(path)}` : ''; - const res = await fetch(`${BASE_URL}/files/browse${qs}`); + browseFiles: async (path?: string, campaignId?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => { + const params = new URLSearchParams(); + if (path) params.set('path', path); + if (campaignId) params.set('campaign_id', campaignId); + const qs = params.toString(); + const res = await fetch(`${BASE_URL}/files/browse${qs ? `?${qs}` : ''}`); if (!res.ok) throw new Error('Failed to browse files'); return res.json(); }, - deleteFiles: async (paths: string[]): Promise<{ deleted: string[]; errors: any[] }> => { - const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) }); + deleteFiles: async (paths: string[], campaignId?: string): Promise<{ deleted: string[]; errors: any[] }> => { + const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths, campaign_id: campaignId }) }); if (!res.ok) throw new Error('Failed to delete files'); return res.json(); }, - uploadFile: async (file: File, dir?: string): Promise<{ path: string }> => { + uploadFile: async (file: File, dir?: string, campaignId?: string): Promise<{ path: string }> => { const form = new FormData(); form.append('file', file); if (dir) form.append('dir', dir); + if (campaignId) form.append('campaign_id', campaignId); const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form }); if (!res.ok) throw new Error('Failed to upload file'); return res.json(); }, - copyFile: async (source: string, dest_dir?: string): Promise<{ source: string; dest: string }> => { - const res = await fetch(`${BASE_URL}/files/copy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, dest_dir }) }); - if (!res.ok) throw new Error('Failed to copy file'); - return res.json(); + downloadFileUrl: (path: string, campaignId?: string): string => { + const params = new URLSearchParams({ path }); + if (campaignId) params.set('campaign_id', campaignId); + return `${BASE_URL}/files/download?${params.toString()}`; }, - downloadFileUrl: (path: string): string => { - return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`; - }, - viewFileUrl: (path: string): string => { - return `${BASE_URL}/files/view?path=${encodeURIComponent(path)}`; + viewFileUrl: (path: string, campaignId?: string): string => { + const params = new URLSearchParams({ path }); + if (campaignId) params.set('campaign_id', campaignId); + return `${BASE_URL}/files/view?${params.toString()}`; }, }; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..061cc61 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; +import Sidebar from "./Sidebar"; +import { CampaignProvider } from "../contexts/CampaignContext"; + +export default function Layout() { + return ( + +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..98d3c60 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,95 @@ +import { useState, useRef, useEffect } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useCampaign } from "../contexts/CampaignContext"; + +const NAV = [ + { to: "/", label: "Transcriptions" }, + { to: "/files", label: "File Browser" }, + { to: "/settings", label: "Settings" }, + { to: "/diagnostics", label: "System Check" }, +]; + +export default function Sidebar() { + const { pathname } = useLocation(); + const { currentCampaign, setCampaign, campaigns } = useCampaign(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const nav = useNavigate(); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + return ( + + ); +} diff --git a/frontend/src/contexts/CampaignContext.tsx b/frontend/src/contexts/CampaignContext.tsx new file mode 100644 index 0000000..4d12f6d --- /dev/null +++ b/frontend/src/contexts/CampaignContext.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import { campaignApi, type Campaign } from '../api'; + +interface CampaignContextType { + currentCampaign: Campaign | null; + setCampaign: (c: Campaign | null) => void; + campaigns: Campaign[]; + refreshCampaigns: () => void; +} + +const CampaignContext = createContext(null); + +export function CampaignProvider({ children }: { children: ReactNode }) { + const [campaigns, setCampaigns] = useState([]); + const [currentCampaign, setCurrentCampaign] = useState(null); + + const refreshCampaigns = useCallback(async () => { + try { + const list = await campaignApi.list(); + setCampaigns(list); + } catch {} + }, []); + + useEffect(() => { + refreshCampaigns(); + }, [refreshCampaigns]); + + useEffect(() => { + if (campaigns.length === 0) return; + if (!currentCampaign) { + const stored = localStorage.getItem('campaignId'); + const match = stored ? campaigns.find(c => c.id === stored) : null; + setCurrentCampaign(match || campaigns[0]); + } else { + const stillExists = campaigns.find(c => c.id === currentCampaign.id); + if (!stillExists) { + setCurrentCampaign(campaigns[0] || null); + } + } + }, [campaigns]); + + const setCampaign = (c: Campaign | null) => { + if (c) localStorage.setItem('campaignId', c.id); + else localStorage.removeItem('campaignId'); + setCurrentCampaign(c); + }; + + return ( + + {children} + + ); +} + +export function useCampaign() { + const ctx = useContext(CampaignContext); + if (!ctx) throw new Error('useCampaign must be used within CampaignProvider'); + return ctx; +} diff --git a/frontend/src/pages/Campaigns.tsx b/frontend/src/pages/Campaigns.tsx new file mode 100644 index 0000000..ee1e81c --- /dev/null +++ b/frontend/src/pages/Campaigns.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { campaignApi, type Campaign } from '../api'; +import { useCampaign } from '../contexts/CampaignContext'; + +export default function Campaigns() { + const [campaigns, setCampaigns] = useState([]); + const [name, setName] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const nav = useNavigate(); + const { setCampaign, refreshCampaigns } = useCampaign(); + + const load = async () => { + const list = await campaignApi.list(); + setCampaigns(list); + }; + + useEffect(() => { load(); }, []); + + const create = async () => { + if (!name.trim()) return; + const c = await campaignApi.create(name.trim()); + setName(''); + await load(); + await refreshCampaigns(); + setCampaign(c); + nav('/'); + }; + + const startEdit = (c: Campaign) => { + setEditingId(c.id); + setEditName(c.name); + }; + + const saveEdit = async (id: string) => { + if (!editName.trim()) return; + await campaignApi.update(id, { name: editName.trim() }); + setEditingId(null); + await load(); + await refreshCampaigns(); + }; + + const deleteCampaign = async (id: string) => { + if (!confirm('Delete this campaign? Sessions will be unlinked (notes/history preserved).')) return; + await campaignApi.delete(id); + await load(); + await refreshCampaigns(); + }; + + const select = (c: Campaign) => { + setCampaign(c); + nav('/'); + }; + + return ( +
+
Nat20 Notes
+

Campaigns

+ +
+

New campaign

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && create()} + /> + +
+
+ +
+ {campaigns.length === 0 && ( +

No campaigns yet — create one above.

+ )} + {campaigns.map((c) => ( +
+
select(c)}> + {editingId === c.id ? ( + setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEdit(c.id); + if (e.key === 'Escape') setEditingId(null); + }} + onBlur={() => saveEdit(c.id)} + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> +
{c.name}
+
+ {c.session_count} session{c.session_count !== 1 ? 's' : ''} + {c.description && ` \u00B7 ${c.description}`} +
+ + )} +
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Diagnostics.tsx b/frontend/src/pages/Diagnostics.tsx index 4773c71..e334cdd 100644 --- a/frontend/src/pages/Diagnostics.tsx +++ b/frontend/src/pages/Diagnostics.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; import { api } from "../api"; const LABELS: Record = { @@ -27,7 +26,6 @@ export default function Diagnostics() { return (
- ← Back

System check

Run this before your first session, or any time something isn't working.

diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx index e5ad6b4..194277b 100644 --- a/frontend/src/pages/Files.tsx +++ b/frontend/src/pages/Files.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; +import { useCampaign } from "../contexts/CampaignContext"; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -47,11 +48,11 @@ function isText(path: string) { } export default function Files() { + const { currentCampaign } = useCampaign(); const [entries, setEntries] = useState([]); const [currentPath, setCurrentPath] = useState(""); const [parentPath, setParentPath] = useState(null); const [selectedPaths, setSelectedPaths] = useState>(new Set()); - const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null); const [loading, setLoading] = useState(true); const [toast, setToast] = useState(null); const [uploading, setUploading] = useState(false); @@ -59,20 +60,22 @@ export default function Files() { const [expandedContent, setExpandedContent] = useState(null); const [expanding, setExpanding] = useState(false); + const campaignId = currentCampaign?.id; + const load = useCallback(async (path: string) => { setLoading(true); setExpandedPath(null); setExpandedContent(null); try { - const res = await api.browseFiles(path || undefined); + const res = await api.browseFiles(path || undefined, campaignId); setEntries(res.entries); setCurrentPath(res.current_path); setParentPath(res.parent_path); } catch { setEntries([]); } setLoading(false); - }, []); + }, [campaignId]); - useEffect(() => { load(currentPath); }, []); + useEffect(() => { load(currentPath); }, [campaignId]); const navigate = (path: string) => { setSelectedPaths(new Set()); @@ -84,23 +87,11 @@ export default function Files() { setTimeout(() => setToast(null), 2000); }; - // Keyboard shortcuts + // Keyboard shortcuts (delete only) useEffect(() => { const handler = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; - - if ((e.ctrlKey || e.metaKey) && e.key === "c") { - e.preventDefault(); - if (selectedPaths.size > 0) { - const first = entries.find(f => selectedPaths.has(f.path)); - if (first) { setCopiedFile({ path: first.path, name: first.name }); showToast(`Copied: ${first.name}`); } - } - } - if ((e.ctrlKey || e.metaKey) && e.key === "v") { - e.preventDefault(); - if (copiedFile) handlePaste(); - } if (e.key === "Delete" && selectedPaths.size > 0) handleDelete(); if ((e.ctrlKey || e.metaKey) && e.key === "a") { e.preventDefault(); @@ -109,7 +100,7 @@ export default function Files() { }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [selectedPaths, copiedFile, entries]); + }, [selectedPaths, entries]); const toggleSelect = (path: string) => { setSelectedPaths(prev => { @@ -125,7 +116,7 @@ export default function Files() { if (paths.length === 0) return; if (!confirm(`Delete ${paths.length} item(s)?`)) return; try { - const res = await api.deleteFiles(paths); + const res = await api.deleteFiles(paths, campaignId); setSelectedPaths(new Set()); setExpandedPath(null); setExpandedContent(null); @@ -136,18 +127,6 @@ export default function Files() { } }; - const handlePaste = async () => { - if (!copiedFile) return; - try { - const res = await api.copyFile(copiedFile.path, currentPath || undefined); - setCopiedFile(null); - showToast(`Pasted as: ${res.dest.split("/").pop()}`); - load(currentPath); - } catch (e: any) { - showToast(e.message || "Paste failed"); - } - }; - const handleUpload = async () => { const input = document.createElement("input"); input.type = "file"; @@ -156,7 +135,7 @@ export default function Files() { if (!file) return; setUploading(true); try { - await api.uploadFile(file, currentPath || undefined); + await api.uploadFile(file, currentPath || undefined, campaignId); showToast("Uploaded"); load(currentPath); } catch (e: any) { @@ -173,16 +152,16 @@ export default function Files() { setExpandedContent(null); return; } - setExpandedPath(entry.path); - if (isAudio(entry.name)) { - setExpandedContent(null); - } else if (isText(entry.path)) { - setExpanding(true); - try { - const res = await fetch(api.viewFileUrl(entry.path)); - const text = await res.text(); - setExpandedContent(text); - } catch { + setExpandedPath(entry.path); + if (isAudio(entry.name)) { + setExpandedContent(null); + } else if (isText(entry.path)) { + setExpanding(true); + try { + const res = await fetch(api.viewFileUrl(entry.path, campaignId)); + const text = await res.text(); + setExpandedContent(text); + } catch { setExpandedContent("(failed to load)"); } setExpanding(false); @@ -200,7 +179,6 @@ export default function Files() { {toast}
)} - ← Back

File Browser

@@ -209,30 +187,18 @@ export default function Files() {
-
- {copiedFile && ( - - Clipboard: {copiedFile.name} - - )} - -
@@ -311,7 +277,7 @@ export default function Files() {
{isAudio(e.name) ? ( -