Add campaign layer, GPU model caching, file browser cleanup
- Campaigns: new table, CRUD API, React context + provider
- Sessions: scoped to campaigns, paths under campaigns/{id}/
- File browser: scoped per campaign, removed copy/paste/autoPlay
- Sidebar: campaign selector dropdown at top
- Transcribe: GPU model cached/released via job counter
- Jobs: status text updates dynamically in real-time
- Auto-redirect: blocked when summarize job is active
This commit is contained in:
@@ -14,6 +14,20 @@ TRANSCRIPT_DIR = DATA_DIR / "transcriptions"
|
|||||||
NOTES_DIR = DATA_DIR / "notes"
|
NOTES_DIR = DATA_DIR / "notes"
|
||||||
DB_PATH = DATA_DIR / "app.db"
|
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):
|
for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR):
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ def init_db():
|
|||||||
updated_at REAL NOT NULL
|
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
|
# seed defaults if empty
|
||||||
existing = {r["key"] for r in conn.execute("SELECT key FROM settings")}
|
existing = {r["key"] for r in conn.execute("SELECT key FROM settings")}
|
||||||
for k, v in config.DEFAULT_SETTINGS.items():
|
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:
|
def get_job(job_id: str) -> dict | None:
|
||||||
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
return dict(row) if row else None
|
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
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Minimal background job runner.
|
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
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
@@ -9,10 +14,23 @@ from .errors import PipelineError
|
|||||||
from .logging_config import get_logger
|
from .logging_config import get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
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():
|
def _run():
|
||||||
log.info("Job %s starting", job_id)
|
log.info("Job %s starting", job_id)
|
||||||
db.update_job(job_id, status="running", progress="Starting...")
|
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=f"Unexpected error: {e}", error_stage="unknown",
|
||||||
error_detail=traceback.format_exc(),
|
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)
|
_executor.submit(_run)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
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 models as models_router
|
||||||
from .routers import diagnostics as diagnostics_router
|
from .routers import diagnostics as diagnostics_router
|
||||||
from .routers import files as files_router
|
from .routers import files as files_router
|
||||||
|
from .routers import campaigns as campaigns_router
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -54,11 +57,15 @@ app.include_router(jobs_router.router)
|
|||||||
app.include_router(models_router.router)
|
app.include_router(models_router.router)
|
||||||
app.include_router(diagnostics_router.router)
|
app.include_router(diagnostics_router.router)
|
||||||
app.include_router(files_router.router)
|
app.include_router(files_router.router)
|
||||||
|
app.include_router(campaigns_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sessions/{session_id}/audio")
|
@app.get("/api/sessions/{session_id}/audio")
|
||||||
def get_audio(session_id: str):
|
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():
|
if not audio_path.exists():
|
||||||
raise HTTPException(404, "Audio not available for this session")
|
raise HTTPException(404, "Audio not available for this session")
|
||||||
return FileResponse(audio_path, media_type="audio/wav")
|
return FileResponse(audio_path, media_type="audio/wav")
|
||||||
|
|||||||
@@ -13,6 +13,53 @@ from ..logging_config import get_logger
|
|||||||
|
|
||||||
log = get_logger(__name__)
|
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(
|
def transcribe_and_diarize(
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
@@ -33,7 +80,7 @@ def transcribe_and_diarize(
|
|||||||
|
|
||||||
report("Loading transcription model...")
|
report("Loading transcription model...")
|
||||||
try:
|
try:
|
||||||
model = whisperx.load_model(model_size, device, compute_type=compute_type)
|
model = _get_model(model_size, compute_type, device)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
if "out of memory" in str(e).lower():
|
if "out of memory" in str(e).lower():
|
||||||
raise TranscriptionError(
|
raise TranscriptionError(
|
||||||
@@ -59,22 +106,21 @@ def transcribe_and_diarize(
|
|||||||
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
raise TranscriptionError(f"Transcription failed: {e}", cause=e)
|
||||||
finally:
|
|
||||||
del model
|
|
||||||
gc.collect()
|
|
||||||
if device == "cuda":
|
|
||||||
torch.cuda.empty_cache()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
report("Aligning...")
|
report("Aligning...")
|
||||||
align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
|
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)
|
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()
|
gc.collect()
|
||||||
if device == "cuda":
|
if device == "cuda":
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
except Exception as e:
|
|
||||||
raise TranscriptionError(f"Alignment step failed: {e}", cause=e)
|
|
||||||
|
|
||||||
if hf_token:
|
if hf_token:
|
||||||
try:
|
try:
|
||||||
@@ -91,6 +137,14 @@ def transcribe_and_diarize(
|
|||||||
f"your HF token belongs to.\n{msg}", cause=e
|
f"your HF token belongs to.\n{msg}", cause=e
|
||||||
)
|
)
|
||||||
raise DiarizationError(f"Diarization failed: {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:
|
else:
|
||||||
report("No HF token set - skipping speaker diarization (all speech will be unattributed)")
|
report("No HF token set - skipping speaker diarization (all speech will be unattributed)")
|
||||||
|
|
||||||
|
|||||||
47
backend/app/routers/campaigns.py
Normal file
47
backend/app/routers/campaigns.py
Normal file
@@ -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}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import shutil
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
@@ -11,25 +10,14 @@ router = APIRouter(prefix="/api/files", tags=["files"])
|
|||||||
DATA = config.DATA_DIR.resolve()
|
DATA = config.DATA_DIR.resolve()
|
||||||
|
|
||||||
|
|
||||||
def _safe_path(path_str: str) -> Path:
|
def _safe_path(path_str: str, campaign_id: str | None = None) -> Path:
|
||||||
resolved = (DATA / path_str).resolve()
|
root = (config.campaign_dir(campaign_id) if campaign_id else DATA).resolve()
|
||||||
if not str(resolved).startswith(str(DATA)):
|
resolved = (root / path_str).resolve()
|
||||||
|
if not str(resolved).startswith(str(root)):
|
||||||
raise HTTPException(400, "Path traversal denied")
|
raise HTTPException(400, "Path traversal denied")
|
||||||
return resolved
|
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:
|
def _auto_name(parent: Path, name: str) -> Path:
|
||||||
p = parent / name
|
p = parent / name
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
@@ -53,22 +41,29 @@ def _enrich(entries, conn):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@router.get("/browse")
|
||||||
def browse(path: str = ""):
|
def browse(path: str = "", campaign_id: str = None):
|
||||||
clean = path.strip("/")
|
clean = path.strip("/")
|
||||||
entries = []
|
entries = []
|
||||||
conn = db.get_conn()
|
conn = db.get_conn()
|
||||||
|
base = config.campaign_dir(campaign_id) if campaign_id else DATA
|
||||||
|
|
||||||
# Root level — show the three directories
|
|
||||||
if not clean:
|
if not clean:
|
||||||
for name in sorted(["audio", "transcriptions", "notes"]):
|
for name in sorted(["audio", "transcriptions", "notes"]):
|
||||||
entries.append({"name": name, "type": "dir", "path": name})
|
entries.append({"name": name, "type": "dir", "path": name})
|
||||||
return {"entries": entries, "current_path": "", "parent_path": None}
|
return {"entries": entries, "current_path": "", "parent_path": None}
|
||||||
|
|
||||||
# Virtual "notes" directory — list from DB
|
|
||||||
if clean == "notes":
|
if clean == "notes":
|
||||||
rows = conn.execute(
|
if campaign_id:
|
||||||
"SELECT session_id, dm_notes, player_recap, generated_at FROM notes ORDER BY generated_at DESC"
|
rows = conn.execute(
|
||||||
).fetchall()
|
"""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 r in rows:
|
||||||
for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])):
|
for kind, text in (("player_recap", r["player_recap"]), ("dm_notes", r["dm_notes"])):
|
||||||
if not text:
|
if not text:
|
||||||
@@ -85,15 +80,16 @@ def browse(path: str = ""):
|
|||||||
_enrich(entries, conn)
|
_enrich(entries, conn)
|
||||||
return {"entries": entries, "current_path": "notes", "parent_path": ""}
|
return {"entries": entries, "current_path": "notes", "parent_path": ""}
|
||||||
|
|
||||||
# Real directory on disk
|
resolved = (base / clean).resolve()
|
||||||
resolved = DATA / clean
|
if not str(resolved).startswith(str(base.resolve())):
|
||||||
|
raise HTTPException(400, "Path traversal denied")
|
||||||
if not resolved.exists() or not resolved.is_dir():
|
if not resolved.exists() or not resolved.is_dir():
|
||||||
raise HTTPException(404, "Directory not found")
|
raise HTTPException(404, "Directory not found")
|
||||||
|
|
||||||
for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
for f in sorted(resolved.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||||
if f.name.startswith("tmp_"):
|
if f.name.startswith("tmp_"):
|
||||||
continue
|
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}
|
entry = {"name": f.name, "type": "dir" if f.is_dir() else "file", "path": rel}
|
||||||
if not f.is_dir():
|
if not f.is_dir():
|
||||||
entry["size"] = f.stat().st_size
|
entry["size"] = f.stat().st_size
|
||||||
@@ -104,7 +100,7 @@ def browse(path: str = ""):
|
|||||||
|
|
||||||
parts = clean.split("/")
|
parts = clean.split("/")
|
||||||
parent = "/".join(parts[:-1]) if len(parts) > 1 else ""
|
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] = {
|
_MIME_MAP: dict[str, str] = {
|
||||||
@@ -120,8 +116,7 @@ _MIME_MAP: dict[str, str] = {
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/view")
|
@router.get("/view")
|
||||||
def view_file(path: str):
|
def view_file(path: str, campaign_id: str = None):
|
||||||
# Virtual notes file — serve from DB
|
|
||||||
if path.startswith("notes/"):
|
if path.startswith("notes/"):
|
||||||
parts = Path(path).stem.split("_")
|
parts = Path(path).stem.split("_")
|
||||||
session_id = parts[0]
|
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 "")
|
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"})
|
return Response(content=text, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": "inline"})
|
||||||
|
|
||||||
# Real file on disk
|
file_path = _safe_path(path, campaign_id)
|
||||||
file_path = _safe_path(path)
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
@@ -143,8 +137,8 @@ def view_file(path: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/download")
|
@router.get("/download")
|
||||||
def download_file(path: str):
|
def download_file(path: str, campaign_id: str = None):
|
||||||
file_path = _safe_path(path)
|
file_path = _safe_path(path, campaign_id)
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
return FileResponse(file_path, filename=file_path.name)
|
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:
|
if not file_field or not hasattr(file_field, "filename") or not file_field.filename:
|
||||||
raise HTTPException(400, "No file provided")
|
raise HTTPException(400, "No file provided")
|
||||||
dest_dir_str = form.get("dir", "audio")
|
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_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = _auto_name(dest_dir, file_field.filename)
|
dest = _auto_name(dest_dir, file_field.filename)
|
||||||
with open(dest, "wb") as f:
|
with open(dest, "wb") as f:
|
||||||
content = await file_field.read()
|
content = await file_field.read()
|
||||||
f.write(content)
|
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("")
|
@router.delete("")
|
||||||
def delete_files(body: dict):
|
def delete_files(body: dict):
|
||||||
paths = body.get("paths", [])
|
paths = body.get("paths", [])
|
||||||
|
campaign_id = body.get("campaign_id")
|
||||||
deleted = []
|
deleted = []
|
||||||
errors = []
|
errors = []
|
||||||
for p in paths:
|
for p in paths:
|
||||||
try:
|
try:
|
||||||
fp = _safe_path(p)
|
fp = _safe_path(p, campaign_id)
|
||||||
if fp.exists():
|
if fp.exists():
|
||||||
fp.unlink()
|
fp.unlink()
|
||||||
deleted.append(p)
|
deleted.append(p)
|
||||||
@@ -195,30 +192,3 @@ def delete_files(body: dict):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({"path": p, "error": str(e)})
|
errors.append({"path": p, "error": str(e)})
|
||||||
return {"deleted": deleted, "errors": errors}
|
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)),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ def _row_to_dict(row) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_sessions():
|
def list_sessions(campaign_id: str = None):
|
||||||
rows = db.get_conn().execute("SELECT * FROM sessions ORDER BY created_at DESC").fetchall()
|
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 = []
|
sessions = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
s = _row_to_dict(r)
|
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")
|
job_id = db.create_job(session_id, "transcribe")
|
||||||
jobs.submit(job_id, run)
|
jobs.submit(job_id, run, requires_gpu=True)
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@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()
|
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:
|
if upload_path:
|
||||||
src = Path(upload_path)
|
src = Path(upload_path)
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
raise HTTPException(400, f"Uploaded file not found at {upload_path}")
|
raise HTTPException(400, f"Uploaded file not found at {upload_path}")
|
||||||
safe_filename = os.path.basename(src)
|
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))
|
shutil.move(str(src), str(video_path))
|
||||||
elif file:
|
elif file:
|
||||||
safe_filename = os.path.basename(file.filename or f"{session_id}")
|
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:
|
with open(video_path, "wb") as buffer:
|
||||||
shutil.copyfileobj(file.file, buffer)
|
shutil.copyfileobj(file.file, buffer)
|
||||||
else:
|
else:
|
||||||
@@ -133,13 +152,13 @@ async def create_session(name: str = Form(...), file: UploadFile = File(None), u
|
|||||||
|
|
||||||
with db.tx() as conn:
|
with db.tx() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sessions (id, name, original_filename, video_path, status, created_at) "
|
"INSERT INTO sessions (id, name, original_filename, video_path, campaign_id, status, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, 'uploaded', ?)",
|
"VALUES (?, ?, ?, ?, ?, 'uploaded', ?)",
|
||||||
(session_id, name, safe_filename, str(video_path), db.now()),
|
(session_id, name, safe_filename, str(video_path), campaign_id, db.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
audio_path = audio_dir / f"{session_id}.wav"
|
||||||
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
transcript_path = transcript_dir / f"{session_id}.json"
|
||||||
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
|
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
|
||||||
|
|
||||||
return {"session_id": session_id, "job_id": job_id}
|
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")
|
raise HTTPException(404, "Session not found")
|
||||||
session = _row_to_dict(row)
|
session = _row_to_dict(row)
|
||||||
video_path = Path(session["video_path"])
|
video_path = Path(session["video_path"])
|
||||||
audio_path = config.AUDIO_DIR / f"{session_id}.wav"
|
campaign_id = session.get("campaign_id")
|
||||||
transcript_path = config.TRANSCRIPT_DIR / f"{session_id}.json"
|
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)
|
job_id = _run_transcription(session_id, video_path, audio_path, transcript_path)
|
||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Notes from './pages/Notes';
|
|||||||
import Files from './pages/Files';
|
import Files from './pages/Files';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import Diagnostics from './pages/Diagnostics';
|
import Diagnostics from './pages/Diagnostics';
|
||||||
|
import Campaigns from './pages/Campaigns';
|
||||||
|
import Layout from './components/Layout';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -41,8 +43,9 @@ function App() {
|
|||||||
|
|
||||||
{/* If configured, serve the main sessions workspace */}
|
{/* If configured, serve the main sessions workspace */}
|
||||||
{isConfigured ? (
|
{isConfigured ? (
|
||||||
<>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Sessions />} />
|
<Route path="/" element={<Sessions />} />
|
||||||
|
<Route path="/campaigns" element={<Campaigns />} />
|
||||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
||||||
<Route path="/sessions/:id/notes" element={<Notes />} />
|
<Route path="/sessions/:id/notes" element={<Notes />} />
|
||||||
@@ -50,7 +53,7 @@ function App() {
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/diagnostics" element={<Diagnostics />} />
|
<Route path="/diagnostics" element={<Diagnostics />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
<Route path="*" element={<Navigate to="/setup" />} />
|
<Route path="*" element={<Navigate to="/setup" />} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export interface Job {
|
|||||||
updated_at: number;
|
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 type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
|
||||||
|
|
||||||
export const jobApi = {
|
export const jobApi = {
|
||||||
@@ -55,6 +63,41 @@ export const jobApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const campaignApi = {
|
||||||
|
list: async (): Promise<Campaign[]> => {
|
||||||
|
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<Campaign> => {
|
||||||
|
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<Campaign> => {
|
||||||
|
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<Pick<Campaign, 'name' | 'description'>>): Promise<Campaign> => {
|
||||||
|
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<void> => {
|
||||||
|
const res = await fetch(`${BASE_URL}/campaigns/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete campaign');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Settings
|
// Settings
|
||||||
getSettings: async (): Promise<AppSettings> => {
|
getSettings: async (): Promise<AppSettings> => {
|
||||||
@@ -75,17 +118,19 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
listSessions: async (): Promise<any[]> => {
|
listSessions: async (campaignId?: string): Promise<any[]> => {
|
||||||
const res = await fetch(`${BASE_URL}/sessions`);
|
const qs = campaignId ? `?campaign_id=${encodeURIComponent(campaignId)}` : '';
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions${qs}`);
|
||||||
return res.json();
|
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;
|
const CHUNK_THRESHOLD = 90 * 1024 * 1024;
|
||||||
if (file.size > CHUNK_THRESHOLD) {
|
if (file.size > CHUNK_THRESHOLD) {
|
||||||
const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
|
const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
formData.append('upload_path', result.filepath);
|
formData.append('upload_path', result.filepath);
|
||||||
|
if (campaignId) formData.append('campaign_id', campaignId);
|
||||||
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||||
if (!res.ok) throw new Error('Failed to create session');
|
if (!res.ok) throw new Error('Failed to create session');
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -93,6 +138,7 @@ export const api = {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', name);
|
formData.append('name', name);
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
if (campaignId) formData.append('campaign_id', campaignId);
|
||||||
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||||
if (!res.ok) throw new Error('Failed to create session');
|
if (!res.ok) throw new Error('Failed to create session');
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -195,34 +241,37 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
browseFiles: async (path?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => {
|
browseFiles: async (path?: string, campaignId?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => {
|
||||||
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
|
const params = new URLSearchParams();
|
||||||
const res = await fetch(`${BASE_URL}/files/browse${qs}`);
|
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');
|
if (!res.ok) throw new Error('Failed to browse files');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
deleteFiles: async (paths: string[]): Promise<{ deleted: string[]; errors: any[] }> => {
|
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 }) });
|
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');
|
if (!res.ok) throw new Error('Failed to delete files');
|
||||||
return res.json();
|
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();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
if (dir) form.append('dir', dir);
|
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 });
|
const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form });
|
||||||
if (!res.ok) throw new Error('Failed to upload file');
|
if (!res.ok) throw new Error('Failed to upload file');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
copyFile: async (source: string, dest_dir?: string): Promise<{ source: string; dest: string }> => {
|
downloadFileUrl: (path: string, campaignId?: string): string => {
|
||||||
const res = await fetch(`${BASE_URL}/files/copy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, dest_dir }) });
|
const params = new URLSearchParams({ path });
|
||||||
if (!res.ok) throw new Error('Failed to copy file');
|
if (campaignId) params.set('campaign_id', campaignId);
|
||||||
return res.json();
|
return `${BASE_URL}/files/download?${params.toString()}`;
|
||||||
},
|
},
|
||||||
downloadFileUrl: (path: string): string => {
|
viewFileUrl: (path: string, campaignId?: string): string => {
|
||||||
return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`;
|
const params = new URLSearchParams({ path });
|
||||||
},
|
if (campaignId) params.set('campaign_id', campaignId);
|
||||||
viewFileUrl: (path: string): string => {
|
return `${BASE_URL}/files/view?${params.toString()}`;
|
||||||
return `${BASE_URL}/files/view?path=${encodeURIComponent(path)}`;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
16
frontend/src/components/Layout.tsx
Normal file
16
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import { CampaignProvider } from "../contexts/CampaignContext";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<CampaignProvider>
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 ml-60">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</CampaignProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/components/Sidebar.tsx
Normal file
95
frontend/src/components/Sidebar.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<aside className="fixed left-0 top-0 h-screen w-60 bg-panel border-r border-white/5 flex flex-col z-40">
|
||||||
|
<div className="px-6 pt-8 pb-5 border-b border-white/5">
|
||||||
|
<h1 className="font-display text-xl text-brass tracking-tight">Nat20 Notes</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign selector */}
|
||||||
|
<div className="px-3 pt-4 pb-3 relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-white/5 hover:bg-white/10 transition-colors text-left"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{currentCampaign?.name ?? "No campaign"}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink/40 text-xs">{open ? "\u25B2" : "\u25BC"}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-3 right-3 top-full mt-1 rounded-md bg-panel2 border border-white/10 shadow-xl z-50 overflow-hidden">
|
||||||
|
{campaigns.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-white/5 transition-colors flex items-center justify-between ${
|
||||||
|
c.id === currentCampaign?.id ? "text-brass" : "text-ink/70"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCampaign(c);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
{c.id === currentCampaign?.id && <span className="text-brass text-xs">\u2713</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-white/10">
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-3 py-2 text-xs text-ink/50 hover:text-ink hover:bg-white/5 transition-colors"
|
||||||
|
onClick={() => { nav("/campaigns"); setOpen(false); }}
|
||||||
|
>
|
||||||
|
Manage Campaigns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 flex flex-col gap-1 px-3 pt-3">
|
||||||
|
{NAV.map(({ to, label }) => {
|
||||||
|
const active = pathname === to;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-brass/10 text-brass"
|
||||||
|
: "text-ink/50 hover:text-ink hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/contexts/CampaignContext.tsx
Normal file
59
frontend/src/contexts/CampaignContext.tsx
Normal file
@@ -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<CampaignContextType | null>(null);
|
||||||
|
|
||||||
|
export function CampaignProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [currentCampaign, setCurrentCampaign] = useState<Campaign | null>(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 (
|
||||||
|
<CampaignContext.Provider value={{ currentCampaign, setCampaign, campaigns, refreshCampaigns }}>
|
||||||
|
{children}
|
||||||
|
</CampaignContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCampaign() {
|
||||||
|
const ctx = useContext(CampaignContext);
|
||||||
|
if (!ctx) throw new Error('useCampaign must be used within CampaignProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
120
frontend/src/pages/Campaigns.tsx
Normal file
120
frontend/src/pages/Campaigns.tsx
Normal file
@@ -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<Campaign[]>([]);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(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 (
|
||||||
|
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||||
|
<div className="eyebrow mb-2">Nat20 Notes</div>
|
||||||
|
<h1 className="font-display text-4xl mb-8">Campaigns</h1>
|
||||||
|
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<h2 className="font-display text-xl mb-4">New campaign</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Campaign name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" disabled={!name.trim()} onClick={create}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{campaigns.length === 0 && (
|
||||||
|
<p className="text-ink/40">No campaigns yet — create one above.</p>
|
||||||
|
)}
|
||||||
|
{campaigns.map((c) => (
|
||||||
|
<div key={c.id} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent">
|
||||||
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => select(c)}>
|
||||||
|
{editingId === c.id ? (
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-xs text-ink/40 mt-0.5">
|
||||||
|
{c.session_count} session{c.session_count !== 1 ? 's' : ''}
|
||||||
|
{c.description && ` \u00B7 ${c.description}`}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4 shrink-0">
|
||||||
|
<button className="btn-secondary text-xs px-2 py-1" onClick={(e) => { e.stopPropagation(); startEdit(c); }}>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button className="btn-secondary text-xs px-2 py-1 text-rose-400" onClick={(e) => { e.stopPropagation(); deleteCampaign(c.id); }}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
@@ -27,7 +26,6 @@ export default function Diagnostics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl mx-auto py-16 px-4">
|
<div className="max-w-xl mx-auto py-16 px-4">
|
||||||
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
|
||||||
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
|
<h1 className="font-display text-3xl mt-4 mb-2">System check</h1>
|
||||||
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
|
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { useCampaign } from "../contexts/CampaignContext";
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -47,11 +48,11 @@ function isText(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Files() {
|
export default function Files() {
|
||||||
|
const { currentCampaign } = useCampaign();
|
||||||
const [entries, setEntries] = useState<any[]>([]);
|
const [entries, setEntries] = useState<any[]>([]);
|
||||||
const [currentPath, setCurrentPath] = useState("");
|
const [currentPath, setCurrentPath] = useState("");
|
||||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||||
const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -59,20 +60,22 @@ export default function Files() {
|
|||||||
const [expandedContent, setExpandedContent] = useState<string | null>(null);
|
const [expandedContent, setExpandedContent] = useState<string | null>(null);
|
||||||
const [expanding, setExpanding] = useState(false);
|
const [expanding, setExpanding] = useState(false);
|
||||||
|
|
||||||
|
const campaignId = currentCampaign?.id;
|
||||||
|
|
||||||
const load = useCallback(async (path: string) => {
|
const load = useCallback(async (path: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setExpandedPath(null);
|
setExpandedPath(null);
|
||||||
setExpandedContent(null);
|
setExpandedContent(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.browseFiles(path || undefined);
|
const res = await api.browseFiles(path || undefined, campaignId);
|
||||||
setEntries(res.entries);
|
setEntries(res.entries);
|
||||||
setCurrentPath(res.current_path);
|
setCurrentPath(res.current_path);
|
||||||
setParentPath(res.parent_path);
|
setParentPath(res.parent_path);
|
||||||
} catch { setEntries([]); }
|
} catch { setEntries([]); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, [campaignId]);
|
||||||
|
|
||||||
useEffect(() => { load(currentPath); }, []);
|
useEffect(() => { load(currentPath); }, [campaignId]);
|
||||||
|
|
||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
setSelectedPaths(new Set());
|
setSelectedPaths(new Set());
|
||||||
@@ -84,23 +87,11 @@ export default function Files() {
|
|||||||
setTimeout(() => setToast(null), 2000);
|
setTimeout(() => setToast(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts (delete only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
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.key === "Delete" && selectedPaths.size > 0) handleDelete();
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -109,7 +100,7 @@ export default function Files() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [selectedPaths, copiedFile, entries]);
|
}, [selectedPaths, entries]);
|
||||||
|
|
||||||
const toggleSelect = (path: string) => {
|
const toggleSelect = (path: string) => {
|
||||||
setSelectedPaths(prev => {
|
setSelectedPaths(prev => {
|
||||||
@@ -125,7 +116,7 @@ export default function Files() {
|
|||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
if (!confirm(`Delete ${paths.length} item(s)?`)) return;
|
if (!confirm(`Delete ${paths.length} item(s)?`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.deleteFiles(paths);
|
const res = await api.deleteFiles(paths, campaignId);
|
||||||
setSelectedPaths(new Set());
|
setSelectedPaths(new Set());
|
||||||
setExpandedPath(null);
|
setExpandedPath(null);
|
||||||
setExpandedContent(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 handleUpload = async () => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
@@ -156,7 +135,7 @@ export default function Files() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
await api.uploadFile(file, currentPath || undefined);
|
await api.uploadFile(file, currentPath || undefined, campaignId);
|
||||||
showToast("Uploaded");
|
showToast("Uploaded");
|
||||||
load(currentPath);
|
load(currentPath);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -173,16 +152,16 @@ export default function Files() {
|
|||||||
setExpandedContent(null);
|
setExpandedContent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedPath(entry.path);
|
setExpandedPath(entry.path);
|
||||||
if (isAudio(entry.name)) {
|
if (isAudio(entry.name)) {
|
||||||
setExpandedContent(null);
|
setExpandedContent(null);
|
||||||
} else if (isText(entry.path)) {
|
} else if (isText(entry.path)) {
|
||||||
setExpanding(true);
|
setExpanding(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(api.viewFileUrl(entry.path));
|
const res = await fetch(api.viewFileUrl(entry.path, campaignId));
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
setExpandedContent(text);
|
setExpandedContent(text);
|
||||||
} catch {
|
} catch {
|
||||||
setExpandedContent("(failed to load)");
|
setExpandedContent("(failed to load)");
|
||||||
}
|
}
|
||||||
setExpanding(false);
|
setExpanding(false);
|
||||||
@@ -200,7 +179,6 @@ export default function Files() {
|
|||||||
{toast}
|
{toast}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
|
||||||
<h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1>
|
<h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1>
|
||||||
|
|
||||||
<Breadcrumb path={currentPath} onNavigate={navigate} />
|
<Breadcrumb path={currentPath} onNavigate={navigate} />
|
||||||
@@ -209,30 +187,18 @@ export default function Files() {
|
|||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="btn-secondary text-sm" onClick={() => navigate(parentPath || "")} disabled={!parentPath}>
|
<button className="btn-secondary text-sm" onClick={() => navigate(parentPath || "")} disabled={parentPath === null}>
|
||||||
↑ Up
|
↑ Up
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{copiedFile && (
|
|
||||||
<span className="text-xs text-brass font-mono truncate max-w-[200px]" title={copiedFile.path}>
|
|
||||||
Clipboard: {copiedFile.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="btn-secondary text-sm" onClick={handleUpload} disabled={uploading}>
|
<button className="btn-secondary text-sm" onClick={handleUpload} disabled={uploading}>
|
||||||
{uploading ? "Uploading..." : "Upload"}
|
{uploading ? "Uploading..." : "Upload"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-secondary text-sm" onClick={() => {
|
|
||||||
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}`); }
|
|
||||||
}
|
|
||||||
}} disabled={selectedCount === 0}>Copy</button>
|
|
||||||
<button className="btn-secondary text-sm" onClick={handlePaste} disabled={!copiedFile}>Paste</button>
|
|
||||||
<button className="btn-secondary text-sm" onClick={handleDelete} disabled={selectedCount === 0}>Delete</button>
|
<button className="btn-secondary text-sm" onClick={handleDelete} disabled={selectedCount === 0}>Delete</button>
|
||||||
<button className="btn-secondary text-sm" onClick={() => {
|
<button className="btn-secondary text-sm" onClick={() => {
|
||||||
const first = entries.find(f => selectedPaths.has(f.path));
|
const first = entries.find(f => selectedPaths.has(f.path));
|
||||||
if (first) window.open(api.downloadFileUrl(first.path), "_blank");
|
if (first) window.open(api.downloadFileUrl(first.path, campaignId), "_blank");
|
||||||
}} disabled={selectedCount !== 1}>Download</button>
|
}} disabled={selectedCount !== 1}>Download</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +277,7 @@ export default function Files() {
|
|||||||
<td colSpan={6} className="p-0">
|
<td colSpan={6} className="p-0">
|
||||||
<div className="bg-black/20 px-4 py-4">
|
<div className="bg-black/20 px-4 py-4">
|
||||||
{isAudio(e.name) ? (
|
{isAudio(e.name) ? (
|
||||||
<audio controls className="w-full max-w-xl" src={api.viewFileUrl(e.path)} autoPlay>
|
<audio controls className="w-full max-w-xl" src={api.viewFileUrl(e.path, campaignId)}>
|
||||||
Your browser does not support audio playback.
|
Your browser does not support audio playback.
|
||||||
</audio>
|
</audio>
|
||||||
) : expanding ? (
|
) : expanding ? (
|
||||||
|
|||||||
@@ -191,18 +191,21 @@ export default function SessionDetail() {
|
|||||||
return () => link.remove();
|
return () => link.remove();
|
||||||
}, [session?.status, id]);
|
}, [session?.status, id]);
|
||||||
|
|
||||||
// Auto-redirect to speakers page once transcription completes
|
// Auto-redirect to speakers page once transcription completes.
|
||||||
|
// Skip if there's an active summarize job (user just came from the speakers
|
||||||
|
// page after starting note generation).
|
||||||
const autoRedirected = useRef(false);
|
const autoRedirected = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.status === 'transcribed') {
|
if (session?.status === 'transcribed') {
|
||||||
if (!autoRedirected.current) {
|
const hasActiveSummarize = job?.job_type === 'summarize' && ['queued', 'running'].includes(job.status);
|
||||||
|
if (!hasActiveSummarize && !autoRedirected.current) {
|
||||||
autoRedirected.current = true;
|
autoRedirected.current = true;
|
||||||
nav(`/sessions/${id}/speakers`);
|
nav(`/sessions/${id}/speakers`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
autoRedirected.current = false;
|
autoRedirected.current = false;
|
||||||
}
|
}
|
||||||
}, [session?.status, id, nav]);
|
}, [session?.status, id, nav, job]);
|
||||||
|
|
||||||
const retryTranscription = async () => {
|
const retryTranscription = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -226,13 +229,17 @@ export default function SessionDetail() {
|
|||||||
|
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
|
const statusText = job && ['queued', 'running'].includes(job.status)
|
||||||
|
? job.progress || 'Waiting...'
|
||||||
|
: (STATUS_LABEL[session.status] ?? session.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-16 px-4">
|
<div className="max-w-2xl mx-auto py-16 px-4">
|
||||||
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
|
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
|
||||||
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||||
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
|
<h1 className="font-display text-3xl mt-4 mb-1">{session.name}</h1>
|
||||||
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
|
<p className="text-ink/40 font-mono text-xs mb-6">{session.original_filename}</p>
|
||||||
<p className="text-brass mb-8">{STATUS_LABEL[session.status] ?? session.status}</p>
|
<p className="text-brass mb-8">{statusText}</p>
|
||||||
|
|
||||||
{job && (
|
{job && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { useCampaign } from "../contexts/CampaignContext";
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
uploaded: "Ready to transcribe",
|
uploaded: "Ready to transcribe",
|
||||||
@@ -10,13 +11,16 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Sessions() {
|
export default function Sessions() {
|
||||||
|
const { currentCampaign } = useCampaign();
|
||||||
const [sessions, setSessions] = useState<any[]>([]);
|
const [sessions, setSessions] = useState<any[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
|
||||||
const refresh = useCallback(() => api.listSessions().then(setSessions), []);
|
const refresh = useCallback(() => {
|
||||||
|
return api.listSessions(currentCampaign?.id).then(setSessions);
|
||||||
|
}, [currentCampaign?.id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
@@ -32,15 +36,19 @@ export default function Sessions() {
|
|||||||
const upload = async () => {
|
const upload = async () => {
|
||||||
if (!file || !name) return;
|
if (!file || !name) return;
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
const { session_id } = await api.createSession(name, file, setUploadProgress);
|
const { session_id } = await api.createSession(name, file, setUploadProgress, currentCampaign?.id);
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
nav(`/sessions/${session_id}`);
|
nav(`/sessions/${session_id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto py-16 px-4">
|
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||||
<div className="eyebrow mb-2">Nat20 Notes</div>
|
<div className="eyebrow mb-2">
|
||||||
<h1 className="font-display text-4xl mb-8">Your Transcriptions</h1>
|
{currentCampaign?.name ?? "Nat20 Notes"}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-4xl mb-8">
|
||||||
|
{currentCampaign ? `${currentCampaign.name} \u2014 Sessions` : "Sessions"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="card p-6 mb-8">
|
<div className="card p-6 mb-8">
|
||||||
<h2 className="font-display text-xl mb-4">New transcription</h2>
|
<h2 className="font-display text-xl mb-4">New transcription</h2>
|
||||||
@@ -86,11 +94,6 @@ export default function Sessions() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 flex gap-4">
|
|
||||||
<Link to="/files" className="text-sm text-ink/40 hover:text-brass">Files</Link>
|
|
||||||
<Link to="/diagnostics" className="text-sm text-ink/40 hover:text-brass">System check</Link>
|
|
||||||
<Link to="/settings" className="text-sm text-ink/40 hover:text-brass">Settings</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -22,7 +21,6 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-16 px-4">
|
<div className="max-w-4xl mx-auto py-16 px-4">
|
||||||
<Link to="/" className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
|
||||||
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
|
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user