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:
KansaiGaijin
2026-07-10 21:41:29 +12:00
parent db19cfd05e
commit bc7ac32de3
19 changed files with 702 additions and 182 deletions

View File

@@ -8,6 +8,8 @@ import Notes from './pages/Notes';
import Files from './pages/Files';
import Settings from './pages/Settings';
import Diagnostics from './pages/Diagnostics';
import Campaigns from './pages/Campaigns';
import Layout from './components/Layout';
import { api } from './api';
function App() {
@@ -41,8 +43,9 @@ function App() {
{/* If configured, serve the main sessions workspace */}
{isConfigured ? (
<>
<Route element={<Layout />}>
<Route path="/" element={<Sessions />} />
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} />
@@ -50,7 +53,7 @@ function App() {
<Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} />
</>
</Route>
) : (
<Route path="*" element={<Navigate to="/setup" />} />
)}

View File

@@ -36,6 +36,14 @@ export interface Job {
updated_at: number;
}
export interface Campaign {
id: string;
name: string;
description: string;
created_at: number;
session_count: number;
}
export type DeleteStrategy = 'all' | 'artifacts_only' | 'none';
export const jobApi = {
@@ -55,6 +63,41 @@ export const jobApi = {
},
};
export const campaignApi = {
list: async (): Promise<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 = {
// Settings
getSettings: async (): Promise<AppSettings> => {
@@ -75,17 +118,19 @@ export const api = {
},
// Sessions
listSessions: async (): Promise<any[]> => {
const res = await fetch(`${BASE_URL}/sessions`);
listSessions: async (campaignId?: string): Promise<any[]> => {
const qs = campaignId ? `?campaign_id=${encodeURIComponent(campaignId)}` : '';
const res = await fetch(`${BASE_URL}/sessions${qs}`);
return res.json();
},
createSession: async (name: string, file: File, onProgress?: (pct: number) => void): Promise<{ session_id: string; job_id: string }> => {
createSession: async (name: string, file: File, onProgress?: (pct: number) => void, campaignId?: string): Promise<{ session_id: string; job_id: string }> => {
const CHUNK_THRESHOLD = 90 * 1024 * 1024;
if (file.size > CHUNK_THRESHOLD) {
const result = await api.uploadFileInChunks(file, onProgress || (() => {}));
const formData = new FormData();
formData.append('name', name);
formData.append('upload_path', result.filepath);
if (campaignId) formData.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session');
return res.json();
@@ -93,6 +138,7 @@ export const api = {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
if (campaignId) formData.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/sessions`, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Failed to create session');
return res.json();
@@ -195,34 +241,37 @@ export const api = {
},
// Files
browseFiles: async (path?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => {
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
const res = await fetch(`${BASE_URL}/files/browse${qs}`);
browseFiles: async (path?: string, campaignId?: string): Promise<{ entries: any[]; current_path: string; parent_path: string | null }> => {
const params = new URLSearchParams();
if (path) params.set('path', path);
if (campaignId) params.set('campaign_id', campaignId);
const qs = params.toString();
const res = await fetch(`${BASE_URL}/files/browse${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error('Failed to browse files');
return res.json();
},
deleteFiles: async (paths: string[]): Promise<{ deleted: string[]; errors: any[] }> => {
const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths }) });
deleteFiles: async (paths: string[], campaignId?: string): Promise<{ deleted: string[]; errors: any[] }> => {
const res = await fetch(`${BASE_URL}/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths, campaign_id: campaignId }) });
if (!res.ok) throw new Error('Failed to delete files');
return res.json();
},
uploadFile: async (file: File, dir?: string): Promise<{ path: string }> => {
uploadFile: async (file: File, dir?: string, campaignId?: string): Promise<{ path: string }> => {
const form = new FormData();
form.append('file', file);
if (dir) form.append('dir', dir);
if (campaignId) form.append('campaign_id', campaignId);
const res = await fetch(`${BASE_URL}/files/upload`, { method: 'POST', body: form });
if (!res.ok) throw new Error('Failed to upload file');
return res.json();
},
copyFile: async (source: string, dest_dir?: string): Promise<{ source: string; dest: string }> => {
const res = await fetch(`${BASE_URL}/files/copy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, dest_dir }) });
if (!res.ok) throw new Error('Failed to copy file');
return res.json();
downloadFileUrl: (path: string, campaignId?: string): string => {
const params = new URLSearchParams({ path });
if (campaignId) params.set('campaign_id', campaignId);
return `${BASE_URL}/files/download?${params.toString()}`;
},
downloadFileUrl: (path: string): string => {
return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`;
},
viewFileUrl: (path: string): string => {
return `${BASE_URL}/files/view?path=${encodeURIComponent(path)}`;
viewFileUrl: (path: string, campaignId?: string): string => {
const params = new URLSearchParams({ path });
if (campaignId) params.set('campaign_id', campaignId);
return `${BASE_URL}/files/view?${params.toString()}`;
},
};

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
const LABELS: Record<string, string> = {
@@ -27,7 +26,6 @@ export default function Diagnostics() {
return (
<div className="max-w-xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; 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>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useCampaign } from "../contexts/CampaignContext";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -47,11 +48,11 @@ function isText(path: string) {
}
export default function Files() {
const { currentCampaign } = useCampaign();
const [entries, setEntries] = useState<any[]>([]);
const [currentPath, setCurrentPath] = useState("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [copiedFile, setCopiedFile] = useState<{ path: string; name: string } | null>(null);
const [loading, setLoading] = useState(true);
const [toast, setToast] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
@@ -59,20 +60,22 @@ export default function Files() {
const [expandedContent, setExpandedContent] = useState<string | null>(null);
const [expanding, setExpanding] = useState(false);
const campaignId = currentCampaign?.id;
const load = useCallback(async (path: string) => {
setLoading(true);
setExpandedPath(null);
setExpandedContent(null);
try {
const res = await api.browseFiles(path || undefined);
const res = await api.browseFiles(path || undefined, campaignId);
setEntries(res.entries);
setCurrentPath(res.current_path);
setParentPath(res.parent_path);
} catch { setEntries([]); }
setLoading(false);
}, []);
}, [campaignId]);
useEffect(() => { load(currentPath); }, []);
useEffect(() => { load(currentPath); }, [campaignId]);
const navigate = (path: string) => {
setSelectedPaths(new Set());
@@ -84,23 +87,11 @@ export default function Files() {
setTimeout(() => setToast(null), 2000);
};
// Keyboard shortcuts
// Keyboard shortcuts (delete only)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
e.preventDefault();
if (selectedPaths.size > 0) {
const first = entries.find(f => selectedPaths.has(f.path));
if (first) { setCopiedFile({ path: first.path, name: first.name }); showToast(`Copied: ${first.name}`); }
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
if (copiedFile) handlePaste();
}
if (e.key === "Delete" && selectedPaths.size > 0) handleDelete();
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
@@ -109,7 +100,7 @@ export default function Files() {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectedPaths, copiedFile, entries]);
}, [selectedPaths, entries]);
const toggleSelect = (path: string) => {
setSelectedPaths(prev => {
@@ -125,7 +116,7 @@ export default function Files() {
if (paths.length === 0) return;
if (!confirm(`Delete ${paths.length} item(s)?`)) return;
try {
const res = await api.deleteFiles(paths);
const res = await api.deleteFiles(paths, campaignId);
setSelectedPaths(new Set());
setExpandedPath(null);
setExpandedContent(null);
@@ -136,18 +127,6 @@ export default function Files() {
}
};
const handlePaste = async () => {
if (!copiedFile) return;
try {
const res = await api.copyFile(copiedFile.path, currentPath || undefined);
setCopiedFile(null);
showToast(`Pasted as: ${res.dest.split("/").pop()}`);
load(currentPath);
} catch (e: any) {
showToast(e.message || "Paste failed");
}
};
const handleUpload = async () => {
const input = document.createElement("input");
input.type = "file";
@@ -156,7 +135,7 @@ export default function Files() {
if (!file) return;
setUploading(true);
try {
await api.uploadFile(file, currentPath || undefined);
await api.uploadFile(file, currentPath || undefined, campaignId);
showToast("Uploaded");
load(currentPath);
} catch (e: any) {
@@ -173,16 +152,16 @@ export default function Files() {
setExpandedContent(null);
return;
}
setExpandedPath(entry.path);
if (isAudio(entry.name)) {
setExpandedContent(null);
} else if (isText(entry.path)) {
setExpanding(true);
try {
const res = await fetch(api.viewFileUrl(entry.path));
const text = await res.text();
setExpandedContent(text);
} catch {
setExpandedPath(entry.path);
if (isAudio(entry.name)) {
setExpandedContent(null);
} else if (isText(entry.path)) {
setExpanding(true);
try {
const res = await fetch(api.viewFileUrl(entry.path, campaignId));
const text = await res.text();
setExpandedContent(text);
} catch {
setExpandedContent("(failed to load)");
}
setExpanding(false);
@@ -200,7 +179,6 @@ export default function Files() {
{toast}
</div>
)}
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-2">File Browser</h1>
<Breadcrumb path={currentPath} onNavigate={navigate} />
@@ -209,30 +187,18 @@ export default function Files() {
<div className="card p-4 mb-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<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}>
&uarr; Up
</button>
</div>
<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}>
{uploading ? "Uploading..." : "Upload"}
</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={() => {
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>
</div>
</div>
@@ -311,7 +277,7 @@ export default function Files() {
<td colSpan={6} className="p-0">
<div className="bg-black/20 px-4 py-4">
{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.
</audio>
) : expanding ? (

View File

@@ -191,18 +191,21 @@ export default function SessionDetail() {
return () => link.remove();
}, [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);
useEffect(() => {
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;
nav(`/sessions/${id}/speakers`);
}
} else {
autoRedirected.current = false;
}
}, [session?.status, id, nav]);
}, [session?.status, id, nav, job]);
const retryTranscription = async () => {
if (!id) return;
@@ -226,13 +229,17 @@ export default function SessionDetail() {
if (!session) return null;
const statusText = job && ['queued', 'running'].includes(job.status)
? job.progress || 'Waiting...'
: (STATUS_LABEL[session.status] ?? session.status);
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">&larr; 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>
<p className="text-brass mb-8">{statusText}</p>
{job && (
<div className="mb-8">

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../api";
import { useCampaign } from "../contexts/CampaignContext";
const STATUS_LABEL: Record<string, string> = {
uploaded: "Ready to transcribe",
@@ -10,13 +11,16 @@ const STATUS_LABEL: Record<string, string> = {
};
export default function Sessions() {
const { currentCampaign } = useCampaign();
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), []);
const refresh = useCallback(() => {
return api.listSessions(currentCampaign?.id).then(setSessions);
}, [currentCampaign?.id]);
useEffect(() => {
refresh();
}, [refresh]);
@@ -32,15 +36,19 @@ export default function Sessions() {
const upload = async () => {
if (!file || !name) return;
setUploadProgress(0);
const { session_id } = await api.createSession(name, file, setUploadProgress);
const { session_id } = await api.createSession(name, file, setUploadProgress, currentCampaign?.id);
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="eyebrow mb-2">
{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">
<h2 className="font-display text-xl mb-4">New transcription</h2>
@@ -86,11 +94,6 @@ export default function Sessions() {
})}
</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>
);
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
export default function Settings() {
@@ -22,7 +21,6 @@ export default function Settings() {
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<Link to="/" className="text-sm text-ink/40 hover:text-brass">&larr; Back</Link>
<h1 className="font-display text-3xl mt-4 mb-8">Settings</h1>
<div className="grid grid-cols-2 gap-6 mb-6">