feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 01:25:15 +01:00
co-authored by Claude Sonnet 4.6
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions
@@ -1,15 +1,49 @@
import React, { useState, useEffect } from 'react';
import Close from '@mui/icons-material/Close';
import { useTranscriptionStore, LLMConfig } from '../../../../../stores/transcriptionStore';
const PROVIDERS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'ollama', label: 'Ollama' },
{ value: 'ollama', label: 'Ollama (local)' },
{ value: 'openrouter', label: 'OpenRouter' },
{ value: 'google', label: 'Google' },
{ value: 'google', label: 'Google Gemini' },
] as const;
const WHISPER_MODELS = [
{ value: 'tiny', label: 'Tiny (fastest, least accurate)' },
{ value: 'tiny.en', label: 'Tiny English' },
{ value: 'base', label: 'Base' },
{ value: 'base.en', label: 'Base English' },
{ value: 'small', label: 'Small' },
{ value: 'small.en', label: 'Small English' },
{ value: 'medium', label: 'Medium' },
{ value: 'medium.en', label: 'Medium English' },
{ value: 'large-v2', label: 'Large v2' },
{ value: 'large-v3', label: 'Large v3 (best accuracy)' },
];
const fieldStyle: React.CSSProperties = {
width: '100%',
padding: '7px 10px',
border: '1px solid var(--color-divider)',
borderRadius: '6px',
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text)',
fontSize: '13px',
outline: 'none',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '12px',
fontWeight: 600,
color: 'var(--color-text-2)',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
const { llmConfig, setLLMConfig } = useTranscriptionStore();
const [form, setForm] = useState<LLMConfig>(llmConfig);
@@ -25,97 +59,196 @@ const LLMConfigModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ is
const handleSave = () => {
setLLMConfig(form);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
setTimeout(() => {
setSaved(false);
onClose();
}, 1000);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)' }} />
{/* Modal panel */}
<div className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full max-w-md mx-auto">
<div
style={{
position: 'relative',
width: '100%',
maxWidth: '420px',
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
borderRadius: '10px',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
overflow: 'hidden',
zIndex: 1,
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="bg-gray-50 px-4 py-3 flex items-center justify-between border-b border-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-900">
LLM Provider Settings
</h3>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--color-divider)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Settings
</span>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
style={{
padding: '2px 6px',
border: 'none',
backgroundColor: 'transparent',
color: 'var(--color-text-2)',
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
}}
>
<Close sx={{ fontSize: 20 }} />
×
</button>
</div>
{/* Content */}
<div className="px-4 py-5 sm:p-6 space-y-4">
{/* Provider dropdown */}
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', maxHeight: '80vh', overflowY: 'auto' }}>
{/* ── Transcription section ── */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Provider
</label>
<div style={{
fontSize: '11px',
fontWeight: 700,
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '10px',
paddingBottom: '6px',
borderBottom: '1px solid var(--color-divider)',
}}>
Transcription
</div>
<label style={labelStyle}>Whisper Model</label>
<select
value={form.provider}
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.whisperModel || 'large-v3'}
onChange={(e) => setForm({ ...form, whisperModel: e.target.value })}
style={fieldStyle}
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
{WHISPER_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '4px' }}>
Larger models are more accurate but slower to load. Server has large-v3 downloaded.
</div>
</div>
{/* Model name */}
{/* ── LLM section ── */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Model
</label>
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div style={{
fontSize: '11px',
fontWeight: 700,
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '10px',
paddingBottom: '6px',
borderBottom: '1px solid var(--color-divider)',
}}>
AI Summary Provider
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder="sk-..."
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={labelStyle}>Provider</label>
<select
value={form.provider}
onChange={(e) => setForm({ ...form, provider: e.target.value as LLMConfig['provider'] })}
style={fieldStyle}
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
{/* Note */}
<p className="text-xs text-gray-500">
API keys are stored locally in your browser only. They are never sent to Supabase or stored on any server.
</p>
<div>
<label style={labelStyle}>Model</label>
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder={
form.provider === 'ollama' ? 'e.g. gemma4:e4b, llama3.2' :
form.provider === 'anthropic' ? 'e.g. claude-sonnet-4-6' :
form.provider === 'google' ? 'e.g. gemini-2.0-flash' :
'e.g. gpt-4o, gpt-4o-mini'
}
style={fieldStyle}
/>
</div>
{form.provider === 'ollama' && (
<div>
<label style={labelStyle}>Ollama Base URL</label>
<input
type="text"
value={form.baseUrl || ''}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder="https://ollama.kevlarai.com"
style={fieldStyle}
/>
</div>
)}
<div>
<label style={labelStyle}>
{form.provider === 'ollama' ? 'API Key (optional — leave blank if unrestricted)' : 'API Key'}
</label>
<input
type="password"
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={form.provider === 'ollama' ? 'Leave blank or any value' : 'sk-...'}
style={fieldStyle}
/>
</div>
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', marginTop: '8px' }}>
API keys are stored in your browser only.
</div>
</div>
{/* Save button */}
<button
onClick={handleSave}
className={`w-full rounded-md px-4 py-2 text-sm font-medium text-white transition-colors ${
saved
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
style={{
padding: '9px',
border: 'none',
borderRadius: '6px',
backgroundColor: saved ? '#16a34a' : '#2563eb',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
transition: 'background-color 200ms',
}}
>
{saved ? '✓ Saved!' : 'Save Settings'}
{saved ? '✓ Saved' : 'Save Settings'}
</button>
</div>
</div>