Initial commit: Nat20 Notes — TTRPG session transcription & summarization
This commit is contained in:
59
frontend/src/App.tsx
Normal file
59
frontend/src/App.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Setup from './pages/Setup';
|
||||
import Sessions from './pages/Sessions';
|
||||
import SessionDetail from './pages/SessionDetail';
|
||||
import Speakers from './pages/Speakers';
|
||||
import Notes from './pages/Notes';
|
||||
import Settings from './pages/Settings';
|
||||
import Diagnostics from './pages/Diagnostics';
|
||||
import { api } from './api';
|
||||
|
||||
function App() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isConfigured, setIsConfigured] = useState(false);
|
||||
|
||||
const checkSettings = () => {
|
||||
api.getSettings()
|
||||
.then(data => {
|
||||
setIsConfigured(!!(data && data.onboarding_completed));
|
||||
})
|
||||
.catch(() => setIsConfigured(false))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkSettings();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* If not configured, force them to the Setup wizard */}
|
||||
<Route
|
||||
path="/setup"
|
||||
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
|
||||
/>
|
||||
|
||||
{/* If configured, serve the main sessions workspace */}
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<Route path="/" element={<Sessions />} />
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
||||
<Route path="/sessions/:id/notes" element={<Notes />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/diagnostics" element={<Diagnostics />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="*" element={<Navigate to="/setup" />} />
|
||||
)}
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
190
frontend/src/api.ts
Normal file
190
frontend/src/api.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
const BASE_URL = '/api';
|
||||
|
||||
// Flat settings schema - must mirror backend/app/config.py's DEFAULT_SETTINGS
|
||||
// exactly. This is the single source of truth; don't reintroduce a nested
|
||||
// shape here without updating config.py, routers/settings.py, Setup.tsx and
|
||||
// Settings.tsx together.
|
||||
export interface AppSettings {
|
||||
onboarding_completed: boolean;
|
||||
whisper_model: string;
|
||||
whisper_compute_type: string;
|
||||
hf_token: string;
|
||||
llm_mode: 'ollama' | 'api';
|
||||
ollama_host: string;
|
||||
ollama_model: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
api_model: string;
|
||||
chunk_word_target: string;
|
||||
world_context: string;
|
||||
world_context_path: string;
|
||||
}
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'done' | 'error';
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
session_id: string;
|
||||
job_type: 'transcribe' | 'summarize';
|
||||
status: JobStatus;
|
||||
progress: string | null;
|
||||
error: string | null;
|
||||
error_stage: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
|
||||
|
||||
export const jobApi = {
|
||||
getJob: async (jobId: string): Promise<Job> => {
|
||||
const res = await fetch(`${BASE_URL}/jobs/${jobId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch job');
|
||||
return res.json();
|
||||
},
|
||||
cancelJob: async (jobId: string): Promise<any> => {
|
||||
const res = await fetch(`${BASE_URL}/jobs/${jobId}/cancel`, { method: 'POST' });
|
||||
return res.json();
|
||||
},
|
||||
deleteJob: async (jobId: string, strategy: DeleteStrategy): Promise<any> => {
|
||||
const res = await fetch(`${BASE_URL}/jobs/${jobId}?strategy=${strategy}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete job');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Settings
|
||||
getSettings: async (): Promise<AppSettings> => {
|
||||
const res = await fetch(`${BASE_URL}/settings`);
|
||||
return res.json();
|
||||
},
|
||||
updateSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
|
||||
const res = await fetch(`${BASE_URL}/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
// kept as an alias since some pages call it by this name
|
||||
saveSettings: async (settings: Partial<AppSettings>): Promise<AppSettings> => {
|
||||
return api.updateSettings(settings);
|
||||
},
|
||||
|
||||
// Sessions
|
||||
listSessions: async (): Promise<any[]> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions`);
|
||||
return res.json();
|
||||
},
|
||||
createSession: async (name: string, file: File, onProgress?: (pct: number) => void): 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);
|
||||
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('Failed to create session');
|
||||
return res.json();
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('file', file);
|
||||
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('Failed to create session');
|
||||
return res.json();
|
||||
},
|
||||
getSession: async (sessionId: string): Promise<any> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch session');
|
||||
return res.json();
|
||||
},
|
||||
retryTranscription: async (sessionId: string): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/transcribe`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to start transcription');
|
||||
return res.json();
|
||||
},
|
||||
audioUrl: (sessionId: string): string => `${BASE_URL}/sessions/${sessionId}/audio`,
|
||||
|
||||
// Speakers
|
||||
getSpeakers: async (sessionId: string): Promise<any[]> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`);
|
||||
return res.json();
|
||||
},
|
||||
getTurns: async (sessionId: string): Promise<any[]> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers/turns`);
|
||||
return res.json();
|
||||
},
|
||||
setSpeakerName: async (sessionId: string, raw_label: string, display_name: string): Promise<void> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/speakers`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ raw_label, display_name }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save speaker name');
|
||||
},
|
||||
|
||||
// Notes
|
||||
getNotes: async (sessionId: string): Promise<any> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes`);
|
||||
if (!res.ok) throw new Error('Notes not generated yet');
|
||||
return res.json();
|
||||
},
|
||||
generateNotes: async (sessionId: string): Promise<{ job_id: string }> => {
|
||||
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/notes/generate`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to start note generation');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// Diagnostics
|
||||
getDiagnostics: async (): Promise<{ ok: boolean; checks: any[] }> => {
|
||||
const res = await fetch(`${BASE_URL}/diagnostics`);
|
||||
if (!res.ok) throw new Error('Failed to reach backend');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
// Jobs (re-exported here so pages that only import `api` still work)
|
||||
deleteJob: jobApi.deleteJob,
|
||||
cancelJob: jobApi.cancelJob,
|
||||
getJob: jobApi.getJob,
|
||||
|
||||
// Large-file (chunked) upload, used automatically by createSession()
|
||||
// when the file exceeds 90MB. Sends up to 3 chunks concurrently.
|
||||
uploadFileInChunks: async (
|
||||
file: File,
|
||||
onProgress: (percent: number) => void
|
||||
): Promise<any> => {
|
||||
const CHUNK_SIZE = 1024 * 1024 * 15;
|
||||
const CONCURRENCY = 3;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
const uploadOne = async (chunkIndex: number): Promise<any> => {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
const formData = new FormData();
|
||||
formData.append('file', chunk, file.name);
|
||||
formData.append('chunk_index', chunkIndex.toString());
|
||||
formData.append('total_chunks', totalChunks.toString());
|
||||
formData.append('filename', file.name);
|
||||
formData.append('upload_id', uploadId);
|
||||
const res = await fetch(`${BASE_URL}/sessions/upload-chunk`, { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error(`Failed to upload chunk ${chunkIndex}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
let lastResult: any = null;
|
||||
for (let i = 0; i < totalChunks; i += CONCURRENCY) {
|
||||
const batch = [];
|
||||
for (let j = i; j < Math.min(i + CONCURRENCY, totalChunks); j++) {
|
||||
batch.push(uploadOne(j));
|
||||
}
|
||||
const results = await Promise.all(batch);
|
||||
lastResult = results[results.length - 1];
|
||||
onProgress(Math.round((Math.min(i + CONCURRENCY, totalChunks) / totalChunks) * 100));
|
||||
}
|
||||
return lastResult;
|
||||
},
|
||||
};
|
||||
66
frontend/src/components/SessionReel.tsx
Normal file
66
frontend/src/components/SessionReel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
const SPEAKER_COLORS = ["#C9A227", "#5F7A61", "#4E6A87", "#7A5670", "#B4523A", "#8A8F5C"];
|
||||
|
||||
function colorFor(label: string, palette: Map<string, string>) {
|
||||
if (!palette.has(label)) {
|
||||
palette.set(label, SPEAKER_COLORS[palette.size % SPEAKER_COLORS.length]);
|
||||
}
|
||||
return palette.get(label)!;
|
||||
}
|
||||
|
||||
export function SessionReel({
|
||||
turns,
|
||||
duration,
|
||||
onSeek,
|
||||
currentTime,
|
||||
}: {
|
||||
turns: { start: number; end: number; raw_speaker: string }[];
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
currentTime: number;
|
||||
}) {
|
||||
const palette = useMemo(() => new Map<string, string>(), []);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const valid = duration > 0 && isFinite(duration);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (!trackRef.current || !valid) return;
|
||||
const rect = trackRef.current.getBoundingClientRect();
|
||||
const frac = (e.clientX - rect.left) / rect.width;
|
||||
onSeek(Math.max(0, Math.min(duration, frac * duration)));
|
||||
};
|
||||
|
||||
const playheadPct = valid ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
onClick={handleClick}
|
||||
className={"relative h-14 rounded-md border overflow-hidden " + (valid ? "bg-deep border-white/10 cursor-pointer" : "bg-panel2 border-white/5")}
|
||||
role="slider"
|
||||
aria-label="Session timeline"
|
||||
>
|
||||
{valid &&
|
||||
turns.map((t, i) => {
|
||||
const left = (t.start / duration) * 100;
|
||||
const width = Math.max(((t.end - t.start) / duration) * 100, 0.15);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-1.5 bottom-1.5 rounded-sm opacity-70 hover:opacity-100 transition"
|
||||
style={{ left: `${left}%`, width: `${width}%`, background: colorFor(t.raw_speaker, palette) }}
|
||||
title={`${t.raw_speaker} @ ${Math.floor(t.start)}s`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-ink shadow-[0_0_6px_1px_rgba(237,230,214,0.6)]"
|
||||
style={{ left: `${playheadPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { colorFor, SPEAKER_COLORS };
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
55
frontend/src/pages/Diagnostics.tsx
Normal file
55
frontend/src/pages/Diagnostics.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
ffmpeg: "ffmpeg",
|
||||
gpu: "GPU / CUDA",
|
||||
huggingface_token: "HuggingFace token",
|
||||
llm_backend: "Summarization backend",
|
||||
disk_space: "Disk space",
|
||||
};
|
||||
|
||||
export default function Diagnostics() {
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
const r = await api.getDiagnostics().catch((e) => ({ ok: false, checks: [], _error: e.message }));
|
||||
setResult(r);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className="text-ink/60 mb-6">Run this before your first session, or any time something isn't working.</p>
|
||||
|
||||
<button className="btn-secondary mb-6" onClick={run} disabled={loading}>
|
||||
{loading ? "Checking..." : "Run checks again"}
|
||||
</button>
|
||||
|
||||
{result?._error && (
|
||||
<div className="card p-4 text-ember">Couldn't reach the backend: {result._error}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{result?.checks?.map((c: any) => (
|
||||
<div key={c.name} className="card p-4 flex gap-3">
|
||||
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${c.ok ? "bg-moss" : "bg-ember"}`} />
|
||||
<div>
|
||||
<div className="font-medium">{LABELS[c.name] || c.name}</div>
|
||||
<div className="text-sm text-ink/60 whitespace-pre-wrap">{c.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/pages/Notes.tsx
Normal file
35
frontend/src/pages/Notes.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function Notes() {
|
||||
const { id } = useParams();
|
||||
const [notes, setNotes] = useState<any>(null);
|
||||
const [tab, setTab] = useState<"dm" | "player">("dm");
|
||||
|
||||
useEffect(() => {
|
||||
api.getNotes(id!).then(setNotes);
|
||||
}, [id]);
|
||||
|
||||
if (!notes) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto py-16 px-4">
|
||||
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||
<h1 className="font-display text-3xl mt-4 mb-6">Session notes</h1>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button className={tab === "dm" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("dm")}>
|
||||
DM notes
|
||||
</button>
|
||||
<button className={tab === "player" ? "btn-primary" : "btn-secondary"} onClick={() => setTab("player")}>
|
||||
Player recap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 whitespace-pre-wrap leading-relaxed">
|
||||
{tab === "dm" ? notes.dm_notes : notes.player_recap}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
frontend/src/pages/SessionDetail.tsx
Normal file
257
frontend/src/pages/SessionDetail.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api, Job } from '../api';
|
||||
|
||||
function Toast({ message, onDone }: { message: string; onDone: () => void }) {
|
||||
useEffect(() => { const t = setTimeout(onDone, 2500); return () => clearTimeout(t); }, [onDone]);
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-emerald-700 text-white px-5 py-3 rounded-xl shadow-2xl text-sm font-medium">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function ElapsedTimer({ createdAt, endedAt }: { createdAt: number; endedAt?: number }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (endedAt !== undefined) {
|
||||
setElapsed(Math.floor(endedAt - createdAt));
|
||||
return;
|
||||
}
|
||||
const tick = () => setElapsed(Math.floor(Date.now() / 1000 - createdAt));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [createdAt, endedAt]);
|
||||
return <span className="font-mono text-xs">{formatDuration(elapsed)}</span>;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
uploaded: 'Ready to transcribe',
|
||||
transcribing: 'Transcribing...',
|
||||
transcribed: 'Ready to name speakers',
|
||||
complete: 'Notes ready',
|
||||
};
|
||||
|
||||
function JobStatusCard({ job, session, onRefresh, onDeleted }: { job: Job; session?: any; onRefresh: () => void; onDeleted: (strategy: string) => void }) {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Are you sure you want to halt this job midway?')) return;
|
||||
setBusy(true);
|
||||
await api.cancelJob(job.id);
|
||||
onRefresh();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const executeDeletion = async (strategy: 'all' | 'artifacts_only' | 'none') => {
|
||||
setBusy(true);
|
||||
await api.deleteJob(job.id, strategy);
|
||||
setShowDeleteModal(false);
|
||||
setBusy(false);
|
||||
onDeleted(strategy);
|
||||
};
|
||||
|
||||
const badgeClass =
|
||||
job.status === 'done'
|
||||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
|
||||
: job.status === 'error'
|
||||
? 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
|
||||
: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 animate-pulse';
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-lg">{job.job_type === 'transcribe' ? 'Transcription' : 'Note generation'}</h3>
|
||||
<span className={`px-2.5 py-1 text-xs font-bold rounded-full uppercase ${badgeClass}`}>
|
||||
{job.status === 'error' ? 'Failed' : job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{job.status === 'error' ? (
|
||||
<div className="bg-rose-950/40 border border-rose-900/50 text-rose-300 text-sm p-3 rounded mb-3 font-mono">
|
||||
<strong>Error{job.error_stage ? ` [${job.error_stage}]` : ''}:</strong> {job.error || 'Unexpected error.'}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-ink/60 mb-3">{job.progress || 'Waiting...'}</p>
|
||||
)}
|
||||
|
||||
{job.status !== 'queued' && job.created_at && (
|
||||
<div className="flex items-center gap-1 text-ink/40 mb-3">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<ElapsedTimer
|
||||
createdAt={job.created_at}
|
||||
endedAt={job.status === 'done' || job.status === 'error' ? job.updated_at : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session?.audio_duration && (
|
||||
<div className="text-xs text-ink/40 mb-3 font-mono">
|
||||
Audio {formatDuration(session.audio_duration)}
|
||||
{session.word_count ? ` \u2022 ${session.word_count.toLocaleString()} words` : ''}
|
||||
{session.language ? ` \u2022 ${session.language}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{(job.status === 'queued' || job.status === 'running') && (
|
||||
<button disabled={busy} onClick={handleCancel} className="btn-secondary text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'error' && job.job_type === 'transcribe' && (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => { setBusy(true); await api.retryTranscription(job.session_id); setBusy(false); onRefresh(); }}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
Restart transcription
|
||||
</button>
|
||||
)}
|
||||
<button disabled={busy} onClick={() => setShowDeleteModal(true)} className="btn-secondary text-sm ml-auto">
|
||||
Delete...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
|
||||
<h4 className="text-lg font-bold mb-2">Clean up session data?</h4>
|
||||
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
|
||||
Choose what to remove along with this job's tracking record.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button onClick={() => executeDeletion('all')} className="w-full text-left card p-3 text-sm">
|
||||
🗑️ <strong>Wipe everything</strong>
|
||||
<span className="block text-xs text-ink/40 mt-0.5">Session record, notes, transcript, and the source audio/video.</span>
|
||||
</button>
|
||||
<button onClick={() => executeDeletion('artifacts_only')} className="w-full text-left card p-3 text-sm">
|
||||
📝 <strong>Keep source file</strong>
|
||||
<span className="block text-xs text-ink/40 mt-0.5">Clears notes/transcript but keeps the original recording.</span>
|
||||
</button>
|
||||
<button onClick={() => executeDeletion('none')} className="w-full text-left card p-3 text-sm">
|
||||
❌ <strong>Just clear this job</strong>
|
||||
<span className="block text-xs text-ink/40 mt-0.5">Only removes the job tracking row. No files touched.</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button onClick={() => setShowDeleteModal(false)} className="text-sm text-ink/50 hover:text-ink">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionDetail() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [session, setSession] = useState<any>(null);
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
api.getSession(id).then((s) => {
|
||||
setSession(s);
|
||||
setJob(s.latest_job || null);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
// Poll while a job is actively running so status/progress stay live.
|
||||
useEffect(() => {
|
||||
if (!job || (job.status !== 'queued' && job.status !== 'running')) return;
|
||||
const interval = setInterval(load, 2500);
|
||||
return () => clearInterval(interval);
|
||||
}, [job, load]);
|
||||
|
||||
// Preload audio as soon as transcription finishes so it's buffered for the speakers page
|
||||
useEffect(() => {
|
||||
if (session?.status !== 'transcribed' || !id) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.as = 'audio';
|
||||
link.href = api.audioUrl(id);
|
||||
document.head.appendChild(link);
|
||||
return () => link.remove();
|
||||
}, [session?.status, id]);
|
||||
|
||||
// Auto-redirect to speakers page once transcription completes
|
||||
const autoRedirected = useRef(false);
|
||||
useEffect(() => {
|
||||
if (session?.status === 'transcribed') {
|
||||
if (!autoRedirected.current) {
|
||||
autoRedirected.current = true;
|
||||
nav(`/sessions/${id}/speakers`);
|
||||
}
|
||||
} else {
|
||||
autoRedirected.current = false;
|
||||
}
|
||||
}, [session?.status, id, nav]);
|
||||
|
||||
const retryTranscription = async () => {
|
||||
if (!id) return;
|
||||
await api.retryTranscription(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const generateNotes = async () => {
|
||||
if (!id) return;
|
||||
await api.generateNotes(id);
|
||||
load();
|
||||
};
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-16 px-4">
|
||||
{toast && <Toast message={toast} onDone={() => setToast(null)} />}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{job && (
|
||||
<div className="mb-8">
|
||||
<JobStatusCard job={job} session={session} onRefresh={load} onDeleted={(strategy) => {
|
||||
if (strategy === 'all') {
|
||||
setToast('Session wiped');
|
||||
setTimeout(() => nav('/'), 600);
|
||||
} else if (strategy === 'artifacts_only') {
|
||||
setToast('Transcript and notes cleared — source file kept');
|
||||
load();
|
||||
} else {
|
||||
setToast('Job record removed');
|
||||
load();
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.status === 'uploaded' && !job && (
|
||||
<button className="btn-primary" onClick={retryTranscription}>Start transcription</button>
|
||||
)}
|
||||
{session.status === 'complete' && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button className="btn-secondary" onClick={generateNotes}>Regenerate notes</button>
|
||||
<Link to={`/sessions/${id}/notes`} className="btn-primary">View notes</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/Sessions.tsx
Normal file
95
frontend/src/pages/Sessions.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
uploaded: "Ready to transcribe",
|
||||
transcribing: "Transcribing...",
|
||||
transcribed: "Ready to name speakers",
|
||||
complete: "Notes ready",
|
||||
};
|
||||
|
||||
export default function Sessions() {
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const nav = useNavigate();
|
||||
|
||||
const refresh = useCallback(() => api.listSessions().then(setSessions), []);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Poll while any session is actively processing
|
||||
useEffect(() => {
|
||||
const hasActive = sessions.some(s => s.status === 'transcribing');
|
||||
if (!hasActive) return;
|
||||
const interval = setInterval(refresh, 2500);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions, refresh]);
|
||||
|
||||
const upload = async () => {
|
||||
if (!file || !name) return;
|
||||
setUploadProgress(0);
|
||||
const { session_id } = await api.createSession(name, file, setUploadProgress);
|
||||
setUploadProgress(null);
|
||||
nav(`/sessions/${session_id}`);
|
||||
};
|
||||
|
||||
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">Your Transcriptions</h1>
|
||||
|
||||
<div className="card p-6 mb-8">
|
||||
<h2 className="font-display text-xl mb-4">New transcription</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Session name, e.g. Session 1 — The Frozen Crypt"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="text-sm text-ink/70"
|
||||
/>
|
||||
<button className="btn-primary self-start" disabled={!file || !name || uploadProgress !== null} onClick={upload}>
|
||||
{uploadProgress !== null ? `Uploading ${uploadProgress}%` : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sessions.length === 0 && (
|
||||
<p className="text-ink/40">No sessions yet — upload a recording to get started.</p>
|
||||
)}
|
||||
{sessions.map((s) => {
|
||||
const job = s.latest_job;
|
||||
const progress = job?.progress;
|
||||
const displayStatus = s.status === 'transcribing' && progress
|
||||
? progress
|
||||
: (STATUS_LABEL[s.status] ?? s.status);
|
||||
const statusColor = job?.status === 'error' ? 'text-rose-400' : 'text-brass';
|
||||
return (
|
||||
<Link key={s.id} to={`/sessions/${s.id}`} className="card p-4 flex items-center justify-between hover:border-brass/40 border border-transparent block">
|
||||
<div>
|
||||
<div className="font-medium">{s.name}</div>
|
||||
<div className="text-xs text-ink/40 font-mono">{s.original_filename}</div>
|
||||
</div>
|
||||
<div className={"text-sm " + statusColor}>{displayStatus}</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex gap-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/Settings.tsx
Normal file
68
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(setSettings);
|
||||
}, []);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const set = (k: string, v: any) => setSettings({ ...settings, [k]: v });
|
||||
|
||||
const save = async () => {
|
||||
await api.updateSettings(settings);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<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-8">Settings</h1>
|
||||
|
||||
<div className="card p-6 space-y-4 mb-6">
|
||||
<h2 className="eyebrow">Transcription</h2>
|
||||
<select className="input w-full" value={settings.whisper_model} onChange={(e) => set("whisper_model", e.target.value)}>
|
||||
<option value="tiny">Tiny</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large-v3">Large v3</option>
|
||||
</select>
|
||||
<input className="input w-full" type="password" placeholder="HuggingFace token" value={settings.hf_token} onChange={(e) => set("hf_token", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="card p-6 space-y-4 mb-6">
|
||||
<h2 className="eyebrow">Summarization</h2>
|
||||
<div className="flex gap-3">
|
||||
<button className={settings.llm_mode === "ollama" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "ollama")}>Local (Ollama)</button>
|
||||
<button className={settings.llm_mode === "api" ? "btn-primary" : "btn-secondary"} onClick={() => set("llm_mode", "api")}>Hosted API</button>
|
||||
</div>
|
||||
{settings.llm_mode === "ollama" ? (
|
||||
<>
|
||||
<input className="input w-full" value={settings.ollama_host} onChange={(e) => set("ollama_host", e.target.value)} />
|
||||
<input className="input w-full" value={settings.ollama_model} onChange={(e) => set("ollama_model", e.target.value)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input className="input w-full" value={settings.api_base_url} onChange={(e) => set("api_base_url", e.target.value)} />
|
||||
<input className="input w-full" type="password" value={settings.api_key} onChange={(e) => set("api_key", e.target.value)} />
|
||||
<input className="input w-full" value={settings.api_model} onChange={(e) => set("api_model", e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-6 space-y-3 mb-6">
|
||||
<h2 className="eyebrow">Campaign context</h2>
|
||||
<textarea className="input w-full h-32" value={settings.world_context} onChange={(e) => set("world_context", e.target.value)} placeholder="Paste world context directly..." />
|
||||
<input className="input w-full" value={settings.world_context_path} onChange={(e) => set("world_context_path", e.target.value)} placeholder="...or path to a file inside the container" />
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={save}>{saved ? "Saved" : "Save settings"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
frontend/src/pages/Setup.tsx
Normal file
205
frontend/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, AppSettings } from '../api';
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
onboarding_completed: false,
|
||||
whisper_model: 'medium',
|
||||
whisper_compute_type: 'int8',
|
||||
hf_token: '',
|
||||
llm_mode: 'ollama',
|
||||
ollama_host: 'http://localhost:11434',
|
||||
ollama_model: 'qwen2.5:7b',
|
||||
api_base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
api_model: 'gpt-4o-mini',
|
||||
chunk_word_target: '2500',
|
||||
world_context: '',
|
||||
world_context_path: '',
|
||||
};
|
||||
|
||||
export default function Setup({ onComplete }: { onComplete: () => void }) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(data => {
|
||||
if (data) {
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...data });
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to connect to settings endpoint:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNext = () => setStep(prev => prev + 1);
|
||||
const handleBack = () => setStep(prev => prev - 1);
|
||||
|
||||
const handleSave = async () => {
|
||||
await api.saveSettings({ ...settings, onboarding_completed: true });
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-2xl bg-slate-800 border border-slate-700 rounded-xl p-8 shadow-2xl">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-teal-400">Nat20Notes Configuration Wizard</h1>
|
||||
<span className="text-sm text-slate-400 font-mono">Step {step} of 2</span>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">1. Audio & Diarization Engine</h2>
|
||||
<p className="text-slate-400 text-sm mb-4">Set up your local Whisper sizing and secure deep speaker parsing models.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Whisper Model Size</label>
|
||||
<select
|
||||
value={settings.whisper_model}
|
||||
onChange={e => setSettings({ ...settings, whisper_model: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-white"
|
||||
>
|
||||
<option value="tiny">Tiny (~39M params)</option>
|
||||
<option value="base">Base (~74M params)</option>
|
||||
<option value="small">Small (~244M params)</option>
|
||||
<option value="medium">Medium (~769M params)</option>
|
||||
<option value="large-v3">Large V3 (~1.5B params - Recommended GPU)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 p-4 rounded border border-slate-700 space-y-3">
|
||||
<label className="block text-sm font-medium">Hugging Face Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="hf_..."
|
||||
value={settings.hf_token}
|
||||
onChange={e => setSettings({ ...settings, hf_token: e.target.value })}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white font-mono"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 leading-relaxed">
|
||||
💡 <strong>Diarization Requirements:</strong> To differentiate between multiple speakers, you must register a token:
|
||||
<ol className="list-decimal list-inside ml-2 mt-1 space-y-1">
|
||||
<li>Create an account on <a href="https://huggingface.co" target="_blank" rel="noreferrer" className="text-teal-400 underline">huggingface.co</a></li>
|
||||
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/speaker-diarization-3.1" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/speaker-diarization-3.1</a></li>
|
||||
<li>Accept licensing conditions for <a href="https://huggingface.co/pyannote/segmentation-3.0" target="_blank" rel="noreferrer" className="text-teal-400 underline">pyannote/segmentation-3.0</a></li>
|
||||
<li>Generate a <strong>Read</strong> token in Settings → Access Tokens.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<button onClick={handleNext} className="bg-teal-600 hover:bg-teal-500 text-white px-5 py-2 rounded font-medium">
|
||||
Continue Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">2. Summarization Engine (LLM)</h2>
|
||||
<p className="text-slate-400 text-sm mb-4">Choose where your post-transcription formatting and smart summaries are generated.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 border-b border-slate-700 pb-4">
|
||||
{(['ollama', 'api'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setSettings({ ...settings, llm_mode: mode })}
|
||||
className={`px-4 py-2 rounded uppercase text-xs font-bold tracking-wider ${settings.llm_mode === mode ? 'bg-teal-600 text-white' : 'bg-slate-900 text-slate-400'}`}
|
||||
>
|
||||
{mode === 'ollama' ? 'Local (Ollama)' : 'Hosted API'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{settings.llm_mode === 'ollama' ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ollama Host Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ollama_host}
|
||||
onChange={e => setSettings({ ...settings, ollama_host: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="qwen2.5:7b"
|
||||
value={settings.ollama_model}
|
||||
onChange={e => setSettings({ ...settings, ollama_model: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Must match your local CLI entry for running "ollama pull <model>".</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">API Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.api_base_url}
|
||||
onChange={e => setSettings({ ...settings, api_base_url: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.api_key}
|
||||
onChange={e => setSettings({ ...settings, api_key: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-white"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.api_model}
|
||||
onChange={e => setSettings({ ...settings, api_model: e.target.value })}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-300">Campaign context (optional)</h3>
|
||||
<textarea
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 h-24"
|
||||
placeholder="Paste world/campaign context directly..."
|
||||
value={settings.world_context}
|
||||
onChange={e => setSettings({ ...settings, world_context: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 font-mono text-sm"
|
||||
placeholder="...or point to a local file"
|
||||
value={settings.world_context_path}
|
||||
onChange={e => setSettings({ ...settings, world_context_path: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4 border-t border-slate-700">
|
||||
<button onClick={handleBack} className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded font-medium">
|
||||
Back
|
||||
</button>
|
||||
<button onClick={handleSave} className="bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2 rounded font-medium">
|
||||
Complete Setup & Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/pages/Speakers.tsx
Normal file
181
frontend/src/pages/Speakers.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { SessionReel, colorFor } from "../components/SessionReel";
|
||||
|
||||
export default function Speakers() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [speakers, setSpeakers] = useState<any[]>([]);
|
||||
const [turns, setTurns] = useState<any[]>([]);
|
||||
const [names, setNames] = useState<Record<string, string>>({});
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [mergeTarget, setMergeTarget] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const paletteRef = useRef(new Map<string, string>());
|
||||
|
||||
const load = () => {
|
||||
api.getSpeakers(id!).then((sp) => {
|
||||
setSpeakers(sp);
|
||||
const n: Record<string, string> = {};
|
||||
sp.forEach((s: any) => (n[s.raw_label] = s.display_name || ""));
|
||||
setNames(n);
|
||||
});
|
||||
api.getTurns(id!).then(setTurns);
|
||||
};
|
||||
useEffect(load, [id]);
|
||||
|
||||
const save = async (raw_label: string) => {
|
||||
await api.setSpeakerName(id!, raw_label, names[raw_label] || "");
|
||||
};
|
||||
|
||||
const playAt = (t: number) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.readyState < 2) return;
|
||||
audio.pause();
|
||||
audio.currentTime = t;
|
||||
const p = audio.play();
|
||||
if (p) p.catch(() => {});
|
||||
};
|
||||
|
||||
const toggleSelected = (label: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(label)) next.delete(label); else next.add(label);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const doMerge = async () => {
|
||||
if (!mergeTarget || selected.size < 2) return;
|
||||
setBusy(true);
|
||||
const sourceLabels = [...selected].filter(l => l !== mergeTarget);
|
||||
await fetch(`/api/sessions/${id}/speakers/merge`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target_label: mergeTarget, source_labels: sourceLabels }),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setMergeTarget("");
|
||||
setBusy(false);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
setBusy(true);
|
||||
await api.generateNotes(id!);
|
||||
setBusy(false);
|
||||
nav(`/sessions/${id}`);
|
||||
};
|
||||
|
||||
const selectedArr = [...selected];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-16 px-4">
|
||||
<Link to={`/sessions/${id}`} className="text-sm text-ink/40 hover:text-brass">← Back</Link>
|
||||
<h1 className="font-display text-3xl mt-4 mb-2">Name your speakers</h1>
|
||||
<p className="text-ink/60 mb-6">Click anywhere on the reel to jump to that moment and hear who's talking.</p>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={api.audioUrl(id!)}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||||
controls
|
||||
className="w-full mb-3"
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<SessionReel turns={turns} duration={duration} currentTime={currentTime} onSeek={playAt} />
|
||||
</div>
|
||||
|
||||
{selected.size >= 2 && (
|
||||
<div className="card p-4 mb-6 flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-ink/60">{selected.size} selected</span>
|
||||
<select
|
||||
className="input text-sm flex-1 min-w-[200px]"
|
||||
value={mergeTarget}
|
||||
onChange={(e) => setMergeTarget(e.target.value)}
|
||||
>
|
||||
<option value="">Merge into...</option>
|
||||
{selectedArr.map((l) => (
|
||||
<option key={l} value={l}>{l} {names[l] ? `(${names[l]})` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-primary text-sm" disabled={!mergeTarget || busy} onClick={doMerge}>
|
||||
{busy ? "Merging..." : "Merge"}
|
||||
</button>
|
||||
<button className="btn-secondary text-sm" onClick={() => { setSelected(new Set()); setMergeTarget(""); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{speakers.map((sp) => (
|
||||
<div key={sp.raw_label} className={"card p-4 " + (selected.has(sp.raw_label) ? "ring-2 ring-brass" : "")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(sp.raw_label)}
|
||||
onChange={() => toggleSelected(sp.raw_label)}
|
||||
className="accent-brass"
|
||||
/>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
|
||||
style={{ background: colorFor(sp.raw_label, paletteRef.current) }}
|
||||
/>
|
||||
<span className="font-mono text-xs text-ink/40">{sp.raw_label}</span>
|
||||
</div>
|
||||
<input
|
||||
className="input w-full mb-3"
|
||||
placeholder="Player or character name"
|
||||
value={names[sp.raw_label] || ""}
|
||||
onChange={(e) => setNames({ ...names, [sp.raw_label]: e.target.value })}
|
||||
onBlur={() => save(sp.raw_label)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
{sp.samples.map((s: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => playAt(s.start)}
|
||||
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate"
|
||||
>
|
||||
▶ {s.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button className="btn-primary" onClick={() => setShowConfirm(true)}>
|
||||
Done naming
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-panel border border-white/10 max-w-md w-full rounded-xl p-6 shadow-2xl">
|
||||
<h4 className="text-lg font-bold mb-2">Generate notes now?</h4>
|
||||
<p className="text-sm text-ink/60 mb-6 leading-relaxed">
|
||||
This will use your speaker names to create session notes. Are you sure?
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={busy} onClick={handleDone} className="btn-primary">
|
||||
{busy ? "Starting..." : "Yes, generate notes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/styles.css
Normal file
32
frontend/src/styles.css
Normal file
@@ -0,0 +1,32 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
::selection {
|
||||
background: #C9A227;
|
||||
color: #14171F;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid #C9A227;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-panel border border-white/5 rounded-lg;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-brass text-deep font-medium px-4 py-2 rounded-md hover:brightness-110 transition disabled:opacity-40 disabled:pointer-events-none;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-panel2 text-ink font-medium px-4 py-2 rounded-md border border-white/10 hover:border-brass/50 transition;
|
||||
}
|
||||
.input {
|
||||
@apply bg-deep border border-white/10 rounded-md px-3 py-2 text-ink placeholder:text-ink/30 focus:border-brass/60;
|
||||
}
|
||||
.eyebrow {
|
||||
@apply font-mono text-xs uppercase tracking-widest text-brass/80;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user