Files
nail/src/app/page.tsx
2026-03-30 20:24:57 +01:00

1207 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/app/page.tsx
"use client";
import {
useState, useRef, useEffect, useCallback, useMemo,
} from "react";
import { useChat } from "@/hooks/useChat";
import { useWhisper } from "@/hooks/useWhisper";
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
import { useLiveVoice } from "@/hooks/useLiveVoice";
import { stopSpeaking } from "@/lib/tts";
// ─── Types ─────────────────────────────────────────────────────────────────────
type Tab = "chat" | "history" | "settings";
type FontSize = "base" | "lg" | "xl";
type Density = "compact" | "comfortable" | "spacious";
type Theme = "midnight" | "slate" | "obsidian";
interface StoredMessage {
id: string;
role: "user" | "assistant";
content: string;
source?: "voice" | "text";
timestamp: number;
}
interface ChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
pinned: boolean;
preview: string;
messages: StoredMessage[];
}
interface UserProfile {
id: string;
name: string;
avatar: string;
createdAt: number;
}
interface AppSettings {
ttsEnabled: boolean;
ttsRate: number;
ttsPitch: number;
whisperModel: string;
whisperDtype: "q8" | "fp16" | "fp32";
chunkLength: number;
fontSize: FontSize;
density: Density;
theme: Theme;
autoScroll: boolean;
sendOnEnter: boolean;
showSourceBadge: boolean;
liveModeDefault: boolean;
maxSessions: number;
language: string;
}
const DEFAULT_SETTINGS: AppSettings = {
ttsEnabled: true,
ttsRate: 1,
ttsPitch: 1,
whisperModel: "Xenova/whisper-tiny.en",
whisperDtype: "q8",
chunkLength: 30,
fontSize: "base",
density: "comfortable",
theme: "midnight",
autoScroll: true,
sendOnEnter: true,
showSourceBadge: true,
liveModeDefault: false,
maxSessions: 50,
language: "en",
};
const AVATARS = ["🧑", "👩", "🧔", "👨‍💻", "👩‍💻", "🧑‍🎤", "🤖", "🦊", "🐺", "🦁"];
// ─── Storage ───────────────────────────────────────────────────────────────────
const S = {
get<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try { return JSON.parse(localStorage.getItem(key) ?? "null") ?? fallback; }
catch { return fallback; }
},
set(key: string, val: unknown) {
if (typeof window === "undefined") return;
localStorage.setItem(key, JSON.stringify(val));
},
};
// ─── Themes ────────────────────────────────────────────────────────────────────
const THEMES: Record<Theme, { bg: string; surface: string; border: string; accent: string }> = {
midnight: { bg: "#08080f", surface: "#0d0d18", border: "rgba(255,255,255,0.06)", accent: "#6366f1" },
slate: { bg: "#0a0f1a", surface: "#0f1624", border: "rgba(255,255,255,0.07)", accent: "#3b82f6" },
obsidian: { bg: "#0c0c0c", surface: "#141414", border: "rgba(255,255,255,0.05)", accent: "#8b5cf6" },
};
// ─── Subcomponents ─────────────────────────────────────────────────────────────
function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200
${on ? "bg-indigo-500" : "bg-white/10"}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow
transition-transform duration-200 ${on ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
);
}
function SettingRow({
label, sub, children,
}: { label: string; sub?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-white/[0.05] last:border-0">
<div className="min-w-0">
<p className="text-[13px] font-medium text-white/75">{label}</p>
{sub && <p className="text-[11px] text-white/25 mt-0.5 leading-relaxed">{sub}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function SliderRow({
label, sub, value, min, max, step, display, onChange,
}: {
label: string; sub?: string; value: number;
min: number; max: number; step: number;
display: string; onChange: (v: number) => void;
}) {
return (
<div className="py-3.5 border-b border-white/[0.05] last:border-0">
<div className="flex justify-between mb-2">
<div>
<p className="text-[13px] font-medium text-white/75">{label}</p>
{sub && <p className="text-[11px] text-white/25 mt-0.5">{sub}</p>}
</div>
<span className="text-[12px] text-indigo-300 font-mono">{display}</span>
</div>
<input type="range" min={min} max={max} step={step} value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-indigo-500
bg-white/[0.08]" />
</div>
);
}
// ─── Main ──────────────────────────────────────────────────────────────────────
export default function Home() {
// Auth
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authStep, setAuthStep] = useState<"loading" | "setup" | "app">("loading");
const [setupName, setSetupName] = useState("");
const [setupAvatar, setSetupAvatar] = useState(AVATARS[0]);
// Settings
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
// Sessions
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [openSessionIds, setOpenSessionIds] = useState<string[]>([]);
const [viewingHistory, setViewingHistory] = useState<ChatSession | null>(null);
// UI
const [activeTab, setActiveTab] = useState<Tab>("chat");
const [sidebarOpen, setSidebarOpen] = useState(true);
const [liveMode, setLiveMode] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [textInput, setTextInput] = useState("");
const [settingsSection, setSettingsSection] = useState<
"voice" | "model" | "ui" | "behaviour" | "account" | "storage"
>("voice");
const { messages, isLoading, sendMessage } = useChat();
const { status: whisperStatus, transcribe } = useWhisper();
const { isRecording, startRecording, stopRecording } = useVoiceRecorder();
const bottomRef = useRef<HTMLDivElement>(null);
const theme = THEMES[settings.theme];
// ── Bootstrap ────────────────────────────────────────────────────────────────
useEffect(() => {
const p = S.get<UserProfile | null>("nail_profile", null);
const s = S.get<AppSettings>("nail_settings", DEFAULT_SETTINGS);
const sv = S.get<ChatSession[]>("nail_sessions", []);
setSettings({ ...DEFAULT_SETTINGS, ...s });
setSessions(sv);
if (p) { setProfile(p); setAuthStep("app"); }
else setAuthStep("setup");
}, []);
// ── Persist settings ──────────────────────────────────────────────────────────
const updateSettings = useCallback(<K extends keyof AppSettings>(key: K, val: AppSettings[K]) => {
setSettings((prev) => {
const next = { ...prev, [key]: val };
S.set("nail_settings", next);
return next;
});
}, []);
// ── Auto-save active session ─────────────────────────────────────────────────
useEffect(() => {
if (messages.length === 0 || !activeSessionId) return;
const firstUser = messages.find((m) => m.role === "user");
const last = messages[messages.length - 1];
const session: ChatSession = {
id: activeSessionId,
title: (firstUser?.content ?? "New conversation").slice(0, 50),
createdAt: Date.now(),
updatedAt: Date.now(),
pinned: false,
preview: (last?.content ?? "").slice(0, 100),
messages: messages.map((m) => ({
id: m.id, role: m.role, content: m.content,
source: m.source, timestamp: Date.now(),
})),
};
setSessions((prev) => {
const others = prev.filter((s) => s.id !== activeSessionId);
const pinned = others.filter((s) => s.pinned);
const unpinned = others.filter((s) => !s.pinned);
const updated = [...pinned, session, ...unpinned].slice(0, settings.maxSessions);
S.set("nail_sessions", updated);
return updated;
});
}, [messages, activeSessionId, settings.maxSessions]);
// ── Session management ───────────────────────────────────────────────────────
const createSession = useCallback(() => {
const id = `s_${Date.now()}`;
setActiveSessionId(id);
setOpenSessionIds((prev) => [...prev.filter((x) => x !== id), id]);
setActiveTab("chat");
}, []);
const openSession = useCallback((id: string) => {
setActiveSessionId(id);
setOpenSessionIds((prev) => prev.includes(id) ? prev : [...prev, id]);
setActiveTab("chat");
setViewingHistory(null);
}, []);
const closeSession = useCallback((id: string) => {
setOpenSessionIds((prev) => {
const next = prev.filter((x) => x !== id);
if (activeSessionId === id) setActiveSessionId(next[next.length - 1] ?? null);
return next;
});
}, [activeSessionId]);
const deleteSession = useCallback((id: string) => {
setSessions((prev) => {
const next = prev.filter((s) => s.id !== id);
S.set("nail_sessions", next);
return next;
});
closeSession(id);
if (viewingHistory?.id === id) setViewingHistory(null);
}, [closeSession, viewingHistory]);
const togglePin = useCallback((id: string) => {
setSessions((prev) => {
const next = prev.map((s) => s.id === id ? { ...s, pinned: !s.pinned } : s);
S.set("nail_sessions", next);
return next;
});
}, []);
// ── Live voice ───────────────────────────────────────────────────────────────
const handleUtterance = useCallback((text: string) => {
stopSpeaking(); sendMessage(text, "voice");
}, [sendMessage]);
const { isSpeaking: vadSpeaking, start: startLive, stop: stopLive } =
useLiveVoice({ onUtterance: handleUtterance, onSpeechStart: () => setIsSpeaking(true) });
useEffect(() => { setIsSpeaking(vadSpeaking); }, [vadSpeaking]);
useEffect(() => {
if (settings.autoScroll) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, settings.autoScroll]);
// ── Handlers ─────────────────────────────────────────────────────────────────
const handleLiveToggle = () => {
if (!liveMode) { setLiveMode(true); startLive(); }
else { setLiveMode(false); stopLive(); setIsSpeaking(false); }
};
const handleTextSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!textInput.trim() || !activeSessionId) return;
sendMessage(textInput, "text");
setTextInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && settings.sendOnEnter) {
e.preventDefault(); handleTextSubmit();
}
};
const handlePTTDown = async () => {
if (whisperStatus !== "ready") return;
stopSpeaking(); await startRecording();
};
const handlePTTUp = async () => {
if (!isRecording) return;
const audioData = await stopRecording();
const text = await transcribe(audioData);
if (text) sendMessage(text, "voice");
};
const handleSetupSubmit = () => {
if (!setupName.trim()) return;
const p: UserProfile = {
id: `u_${Date.now()}`, name: setupName.trim(),
avatar: setupAvatar, createdAt: Date.now(),
};
S.set("nail_profile", p);
setProfile(p);
setAuthStep("app");
createSession();
};
const pttDisabled = whisperStatus !== "ready" || isLoading || liveMode || !activeSessionId;
const statusLine = () => {
if (!activeSessionId) return "Create or open a session to start";
if (liveMode && isSpeaking) return "Hearing you…";
if (liveMode && isLoading) return "Thinking…";
if (liveMode) return "Listening — just speak";
if (whisperStatus === "transcribing") return "Transcribing…";
if (isRecording) return "Recording — release to send";
if (whisperStatus === "loading") return "Loading Whisper model…";
return "Hold to talk, or type below";
};
const fontClass: Record<FontSize, string> = {
base: "text-base", lg: "text-lg", xl: "text-xl",
};
const densityPy: Record<Density, string> = {
compact: "py-1.5", comfortable: "py-2.5", spacious: "py-4",
};
const sortedSessions = useMemo(() =>
[...sessions].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt - a.updatedAt;
}), [sessions]);
// ── Auth gate ─────────────────────────────────────────────────────────────────
if (authStep === "loading") {
return (
<div className="flex h-screen items-center justify-center bg-[#08080f]">
<div className="flex gap-1">
{[0,1,2].map((i) => (
<span key={i} className="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }} />
))}
</div>
</div>
);
}
if (authStep === "setup") {
return (
<div className="flex h-screen items-center justify-center bg-[#08080f] p-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/15 border border-indigo-500/20
flex items-center justify-center text-3xl mx-auto mb-5 shadow-xl shadow-indigo-500/10">
🔨
</div>
<h1 className="text-2xl font-bold text-white tracking-tight">Welcome to Nail</h1>
<p className="text-sm text-white/30 mt-2">Set up your local profile to get started</p>
</div>
<div className="space-y-5 bg-white/[0.03] border border-white/[0.07] rounded-2xl p-6">
{/* Avatar picker */}
<div>
<label className="text-xs font-semibold text-white/30 uppercase tracking-widest block mb-2.5">
Avatar
</label>
<div className="flex flex-wrap gap-2">
{AVATARS.map((av) => (
<button key={av} onClick={() => setSetupAvatar(av)}
className={`w-9 h-9 rounded-xl text-lg transition-all
${setupAvatar === av
? "bg-indigo-500/25 ring-1 ring-indigo-400/50 scale-110"
: "bg-white/[0.04] hover:bg-white/[0.08]"}`}>
{av}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="text-xs font-semibold text-white/30 uppercase tracking-widest block mb-2">
Your Name
</label>
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSetupSubmit()}
placeholder="e.g. Alex"
autoFocus
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-xl px-4 py-3
text-base text-white outline-none focus:ring-1 focus:ring-indigo-500/50
focus:border-indigo-500/30 placeholder-white/15 transition-all"
/>
</div>
<button onClick={handleSetupSubmit} disabled={!setupName.trim()}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-25
py-3 rounded-xl text-sm font-semibold transition-all active:scale-[0.98]
shadow-lg shadow-indigo-500/20">
Get Started
</button>
<p className="text-[11px] text-white/20 text-center leading-relaxed">
Your profile and conversations are stored locally in your browser.
</p>
</div>
</div>
</div>
);
}
// ── App ───────────────────────────────────────────────────────────────────────
return (
<div className="flex h-screen overflow-hidden text-white"
style={{ background: theme.bg, fontFamily: "'Inter', system-ui, sans-serif" }}>
{/* ══════════ SIDEBAR ══════════ */}
<aside
className={`flex flex-col shrink-0 border-r transition-all duration-300 ease-in-out`}
style={{ background: theme.surface, borderColor: theme.border,
width: sidebarOpen ? "260px" : "56px" }}>
{/* Logo */}
<div className="flex items-center gap-3 px-3.5 py-4 border-b shrink-0"
style={{ borderColor: theme.border }}>
<div className="w-8 h-8 rounded-xl flex items-center justify-center text-lg shrink-0"
style={{ background: `${theme.accent}22` }}>
🔨
</div>
{sidebarOpen && (
<div className="flex-1 min-w-0">
<p className="text-[14px] font-bold tracking-tight">Nail</p>
<p className="text-[10px] text-white/20 truncate">Voice Assistant</p>
</div>
)}
<button onClick={() => setSidebarOpen((v) => !v)}
className="text-white/20 hover:text-white/60 transition-colors shrink-0 p-1 ml-auto">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<path d={sidebarOpen ? "M8 2L3 6.5L8 11" : "M5 2L10 6.5L5 11"}
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{/* New session button */}
<div className="p-2 shrink-0">
<button onClick={createSession}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px]
transition-all font-medium text-white/50 hover:text-white/80 hover:bg-white/[0.06]"
style={{ background: "transparent" }}>
<span className="text-base shrink-0"></span>
{sidebarOpen && <span>New Session</span>}
</button>
</div>
{/* Nav tabs */}
<nav className="flex flex-col gap-0.5 px-2 shrink-0">
{([
{ id: "chat", icon: "💬", label: "Chat", badge: openSessionIds.length },
{ id: "history", icon: "🕐", label: "History", badge: sessions.length },
{ id: "settings", icon: "⚙️", label: "Settings", badge: 0 },
] as { id: Tab; icon: string; label: string; badge: number }[]).map(({ id, icon, label, badge }) => (
<button key={id} onClick={() => setActiveTab(id)}
className={`flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] w-full
transition-all ${activeTab === id
? "font-medium"
: "text-white/30 hover:text-white/60 hover:bg-white/[0.04]"}`}
style={activeTab === id
? { background: `${theme.accent}1a`, color: `${theme.accent}dd` } : {}}>
<span className="text-sm shrink-0">{icon}</span>
{sidebarOpen && (
<>
<span className="flex-1 text-left truncate">{label}</span>
{badge > 0 && (
<span className="text-[10px] rounded-full px-1.5 py-0.5 leading-none bg-white/[0.07]
text-white/30">{badge}</span>
)}
</>
)}
</button>
))}
</nav>
{/* Session list (chat tab) */}
{sidebarOpen && activeTab === "chat" && openSessionIds.length > 0 && (
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5 min-h-0">
<p className="text-[10px] font-semibold text-white/20 uppercase tracking-widest px-2 mb-1.5 mt-1">
Open
</p>
{openSessionIds.map((id) => {
const s = sessions.find((x) => x.id === id);
const isActive = id === activeSessionId;
return (
<div key={id}
onClick={() => { setActiveSessionId(id); setActiveTab("chat"); }}
className={`group flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer
transition-all ${isActive ? "bg-white/[0.07]" : "hover:bg-white/[0.04]"}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 transition-all
${isActive ? "bg-indigo-400" : "bg-white/[0.12]"}`} />
<span className="text-[12px] text-white/60 truncate flex-1">
{s?.title ?? "New session"}
</span>
<button onClick={(e) => { e.stopPropagation(); closeSession(id); }}
className="opacity-0 group-hover:opacity-100 text-white/20 hover:text-white/60
transition-all text-[11px] shrink-0">
</button>
</div>
);
})}
</div>
)}
<div className="flex-1" />
{/* Live mode toggle */}
<div className="p-2 border-t shrink-0" style={{ borderColor: theme.border }}>
<button onClick={handleLiveToggle}
className={`flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg text-[13px]
transition-all ${liveMode
? "font-medium"
: "text-white/30 hover:text-white/60 hover:bg-white/[0.04]"}`}
style={liveMode ? { background: "#10b98115", color: "#34d399" } : {}}>
<span className={`w-2 h-2 rounded-full shrink-0 transition-all ${liveMode
? "bg-emerald-400 shadow-[0_0_6px_#34d399] animate-pulse" : "bg-white/15"}`} />
{sidebarOpen && <span>{liveMode ? "Live · On" : "Live Mode"}</span>}
</button>
</div>
{/* Profile */}
<div className="px-3 pb-3 shrink-0">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-base shrink-0"
style={{ background: `${theme.accent}15` }}>
{profile?.avatar ?? "🧑"}
</div>
{sidebarOpen && (
<div className="min-w-0 flex-1">
<p className="text-[12px] font-medium text-white/60 truncate">{profile?.name}</p>
<button onClick={() => setActiveTab("settings")}
className="text-[10px] text-white/20 hover:text-white/40 transition-colors">
Manage profile
</button>
</div>
)}
</div>
</div>
</aside>
{/* ══════════ MAIN ══════════ */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar */}
<header className="flex items-center gap-3 px-6 py-3.5 border-b shrink-0 backdrop-blur-xl"
style={{ background: `${theme.surface}dd`, borderColor: theme.border }}>
{/* Session tabs (chat tab) */}
{activeTab === "chat" && openSessionIds.length > 0 ? (
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto no-scrollbar">
{openSessionIds.map((id) => {
const s = sessions.find((x) => x.id === id);
const isActive = id === activeSessionId;
return (
<button key={id}
onClick={() => setActiveSessionId(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px]
whitespace-nowrap transition-all max-w-[160px] group ${isActive
? "text-white/80 font-medium"
: "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}
style={isActive ? { background: `${theme.accent}18` } : {}}>
<span className="truncate">{s?.title ?? "New session"}</span>
<span
onClick={(e) => { e.stopPropagation(); closeSession(id); }}
className="opacity-0 group-hover:opacity-100 text-white/25 hover:text-white/60
transition-all text-[10px] ml-0.5">
</span>
</button>
);
})}
<button onClick={createSession}
className="text-white/20 hover:text-white/50 transition-colors px-2 py-1.5
text-[13px] shrink-0">
+
</button>
</div>
) : (
<span className="text-[13px] font-semibold text-white/40 capitalize">{activeTab}</span>
)}
<div className="ml-auto flex items-center gap-2 shrink-0">
{/* Whisper status pill */}
<span className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium
${whisperStatus === "ready"
? "bg-emerald-500/10 text-emerald-400"
: whisperStatus === "loading"
? "bg-amber-500/10 text-amber-400"
: whisperStatus === "transcribing"
? "bg-blue-500/10 text-blue-400"
: "bg-white/5 text-white/20"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${whisperStatus === "ready"
? "bg-emerald-400"
: whisperStatus === "loading"
? "bg-amber-400 animate-pulse"
: whisperStatus === "transcribing"
? "bg-blue-400 animate-ping"
: "bg-white/15"}`} />
{whisperStatus}
</span>
{liveMode && (
<span className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium
${isSpeaking ? "bg-emerald-500/20 text-emerald-300" : "bg-emerald-500/8 text-emerald-500/60"}`}>
<span className={`w-1.5 h-1.5 rounded-full bg-emerald-400 ${isSpeaking ? "animate-ping" : ""}`} />
{isSpeaking ? "Speaking" : "Live"}
</span>
)}
{isLoading && (
<div className="flex gap-0.5 items-center px-1">
{[0,1,2].map((i) => (
<span key={i} className="w-1 h-1 rounded-full animate-bounce"
style={{ background: theme.accent, animationDelay: `${i*0.12}s` }} />
))}
</div>
)}
</div>
</header>
{/* Live mode bar */}
{liveMode && (
<div className={`flex items-center justify-center gap-2 py-2 text-[11px] font-medium
transition-all ${isSpeaking
? "bg-emerald-600/80 text-white"
: "bg-emerald-900/20 text-emerald-500/60"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${isSpeaking ? "bg-white animate-ping" : "bg-emerald-500"}`} />
{isSpeaking ? "Speech detected" : "Waiting for speech…"}
</div>
)}
{/* ══ CHAT ══ */}
{activeTab === "chat" && (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-4">
{!activeSessionId ? (
<div className="flex flex-col items-center justify-center h-full gap-5 pb-16">
<div className="w-16 h-16 rounded-2xl flex items-center justify-center text-3xl
border shadow-2xl" style={{ background: `${theme.accent}15`, borderColor: `${theme.accent}25` }}>
🔨
</div>
<div className="text-center">
<p className="text-base font-semibold text-white/50 mb-2">
Welcome back, {profile?.name}
</p>
<p className="text-sm text-white/20">Start a new session or open one from history</p>
</div>
<div className="flex gap-2">
<button onClick={createSession}
className="px-5 py-2.5 rounded-xl text-[13px] font-medium text-white/80
hover:text-white transition-all border hover:bg-white/[0.06]"
style={{ borderColor: theme.border }}>
New session
</button>
<button onClick={() => setActiveTab("history")}
className="px-5 py-2.5 rounded-xl text-[13px] font-medium transition-all
hover:opacity-90"
style={{ background: theme.accent }}>
🕐 Open history
</button>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-4 pb-16 text-center">
<span className="text-5xl opacity-20">💬</span>
<p className="text-sm text-white/25">
{liveMode ? "Live mode is on — just start talking" : "Start talking or type below"}
</p>
</div>
) : (
messages.map((msg) => (
<div key={msg.id}
className={`flex items-end gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{msg.role === "assistant" && (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-[13px]
shrink-0 mb-0.5 border" style={{ background: `${theme.accent}20`, borderColor: theme.border }}>
🔨
</div>
)}
<div className={`max-w-[70%] rounded-2xl px-5 ${densityPy[settings.density]}
${fontClass[settings.fontSize]} leading-relaxed
${msg.role === "user"
? "text-white rounded-br-sm shadow-lg"
: "text-white/75 rounded-bl-sm border"}`}
style={msg.role === "user"
? { background: theme.accent, boxShadow: `0 4px 20px ${theme.accent}35` }
: { background: "rgba(255,255,255,0.04)", borderColor: theme.border }}>
{settings.showSourceBadge && msg.source && (
<span className="text-[10px] opacity-30 block mb-1">
{msg.role === "user"
? (msg.source === "voice" ? "🎙 voice" : "⌨️ typed")
: "🔊 spoken"}
</span>
)}
{msg.content || (
<span className="flex gap-1 items-center opacity-40 py-0.5">
{[0,1,2].map((i) => (
<span key={i} className="w-1.5 h-1.5 bg-white rounded-full animate-bounce"
style={{ animationDelay: `${i*0.12}s` }} />
))}
</span>
)}
</div>
{msg.role === "user" && profile && (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-sm
shrink-0 mb-0.5" style={{ background: `${theme.accent}20` }}>
{profile.avatar}
</div>
)}
</div>
))
)}
<div ref={bottomRef} />
</div>
{/* Controls */}
<div className="shrink-0 border-t px-6 py-5 space-y-4" style={{ borderColor: theme.border, background: `${theme.surface}ee` }}>
{!liveMode && (
<div className="flex justify-center">
<button
onMouseDown={handlePTTDown}
onMouseUp={handlePTTUp}
onTouchStart={(e) => { e.preventDefault(); handlePTTDown(); }}
onTouchEnd={handlePTTUp}
disabled={pttDisabled}
className={`relative w-16 h-16 rounded-full text-2xl transition-all duration-150
select-none ${isRecording
? "scale-110 ring-4 ring-red-500/25"
: whisperStatus === "transcribing"
? "cursor-wait"
: pttDisabled
? "opacity-20 cursor-not-allowed"
: "hover:scale-105 active:scale-95"}`}
style={{
background: isRecording ? "#ef4444"
: whisperStatus === "transcribing" ? "#f59e0b"
: pttDisabled ? "rgba(255,255,255,0.05)"
: theme.accent,
boxShadow: isRecording ? "0 0 30px rgba(239,68,68,0.4)"
: !pttDisabled ? `0 0 20px ${theme.accent}40` : "none",
}}>
{isRecording ? "🔴" : whisperStatus === "transcribing" ? "💬" : "🎙"}
{isRecording && (
<span className="absolute inset-0 rounded-full bg-red-400/20 animate-ping pointer-events-none" />
)}
</button>
</div>
)}
<p className="text-center text-[12px] text-white/25">{statusLine()}</p>
<form onSubmit={handleTextSubmit} className="flex gap-2">
<textarea
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={activeSessionId ? "Type a message…" : "Open a session first…"}
disabled={isLoading || isRecording || !activeSessionId}
rows={1}
className="flex-1 bg-white/[0.04] border rounded-xl px-4 py-3 text-[14px]
outline-none focus:ring-1 placeholder-white/15 disabled:opacity-20
transition-all resize-none leading-snug"
style={{ borderColor: theme.border, caretColor: theme.accent }}
/>
<button type="submit" disabled={isLoading || !textInput.trim() || !activeSessionId}
className="px-4 py-3 rounded-xl text-sm font-semibold transition-all
active:scale-95 disabled:opacity-20 shadow-lg self-end"
style={{ background: theme.accent, boxShadow: `0 4px 14px ${theme.accent}40` }}>
</button>
</form>
</div>
</div>
)}
{/* ══ HISTORY ══ */}
{activeTab === "history" && (
<div className="flex-1 overflow-y-auto">
{viewingHistory ? (
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 px-6 py-3.5 border-b shrink-0"
style={{ borderColor: theme.border }}>
<button onClick={() => setViewingHistory(null)}
className="text-white/25 hover:text-white/60 transition-colors flex items-center gap-1.5 text-[13px]">
Back
</button>
<div className="min-w-0 flex-1 px-2">
<p className="text-[13px] font-semibold text-white/70 truncate">{viewingHistory.title}</p>
<p className="text-[11px] text-white/25 mt-0.5">
{new Date(viewingHistory.updatedAt).toLocaleString()} · {viewingHistory.messages.length} messages
</p>
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => openSession(viewingHistory.id)}
className="text-[12px] px-3 py-1.5 rounded-lg font-medium transition-all"
style={{ background: `${theme.accent}20`, color: theme.accent }}>
Open
</button>
<button onClick={() => deleteSession(viewingHistory.id)}
className="text-[12px] text-red-400/40 hover:text-red-400 transition-colors px-2">
Delete
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
{viewingHistory.messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[70%] rounded-2xl px-5 py-3 text-[14px] leading-relaxed
${msg.role === "user" ? "text-white rounded-br-sm" : "text-white/60 rounded-bl-sm border"}`}
style={msg.role === "user"
? { background: `${theme.accent}90` }
: { background: "rgba(255,255,255,0.03)", borderColor: theme.border }}>
{msg.content}
</div>
</div>
))}
</div>
</div>
) : (
<div className="px-6 py-6">
<div className="flex items-center justify-between mb-5">
<div>
<p className="text-[15px] font-semibold text-white/70">Conversation History</p>
<p className="text-[12px] text-white/25 mt-0.5">{sessions.length} saved session{sessions.length !== 1 ? "s" : ""}</p>
</div>
{sessions.length > 0 && (
<button onClick={() => { S.set("nail_sessions", []); setSessions([]); setOpenSessionIds([]); }}
className="text-[12px] text-red-400/40 hover:text-red-400 transition-colors">
Clear all
</button>
)}
</div>
{sessions.length === 0 ? (
<div className="flex flex-col items-center py-28 gap-3 text-center">
<span className="text-5xl opacity-20">🕐</span>
<p className="text-sm text-white/25">No history yet</p>
<p className="text-xs text-white/15">Conversations are saved automatically</p>
</div>
) : (
<div className="space-y-2">
{sortedSessions.map((session) => (
<div key={session.id}
onClick={() => setViewingHistory(session)}
className="group relative rounded-xl p-4 cursor-pointer transition-all border
hover:bg-white/[0.04]"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="flex items-start gap-3">
{session.pinned && <span className="text-[11px] text-amber-400/60 mt-0.5 shrink-0">📌</span>}
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-white/70 truncate">{session.title}</p>
<p className="text-[12px] text-white/25 mt-0.5 line-clamp-1">{session.preview}</p>
<div className="flex items-center gap-2 text-[11px] text-white/20 mt-2">
<span>{new Date(session.updatedAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}</span>
<span>·</span>
<span>{session.messages.length} messages</span>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all shrink-0">
<button onClick={(e) => { e.stopPropagation(); openSession(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg transition-all"
style={{ background: `${theme.accent}20`, color: theme.accent }}>
Open
</button>
<button onClick={(e) => { e.stopPropagation(); togglePin(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg bg-white/5 text-white/30
hover:text-white/60 transition-all">
{session.pinned ? "Unpin" : "Pin"}
</button>
<button onClick={(e) => { e.stopPropagation(); deleteSession(session.id); }}
className="text-[11px] px-2 py-1 rounded-lg text-red-400/30
hover:text-red-400 transition-all">
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* ══ SETTINGS ══ */}
{activeTab === "settings" && (
<div className="flex flex-1 min-h-0">
{/* Settings sidebar */}
<div className="w-48 border-r flex-col py-4 px-2 shrink-0 hidden md:flex"
style={{ borderColor: theme.border }}>
{([
{ id: "voice", icon: "🎙", label: "Voice & TTS" },
{ id: "model", icon: "🤖", label: "Model" },
{ id: "ui", icon: "🎨", label: "Appearance" },
{ id: "behaviour", icon: "⚡", label: "Behaviour" },
{ id: "account", icon: "👤", label: "Profile" },
{ id: "storage", icon: "💾", label: "Storage" },
] as { id: typeof settingsSection; icon: string; label: string }[]).map(({ id, icon, label }) => (
<button key={id} onClick={() => setSettingsSection(id)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg text-[13px] transition-all text-left w-full
${settingsSection === id ? "font-medium" : "text-white/30 hover:text-white/55 hover:bg-white/[0.04]"}`}
style={settingsSection === id
? { background: `${theme.accent}1a`, color: theme.accent } : {}}>
<span className="text-sm">{icon}</span>
{label}
</button>
))}
</div>
{/* Settings content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
<p className="text-[11px] font-bold text-white/20 uppercase tracking-widest mb-5">
{{
voice: "Voice & TTS", model: "Model", ui: "Appearance",
behaviour: "Behaviour", account: "Profile", storage: "Storage",
}[settingsSection]}
</p>
{/* ─ Voice ─ */}
{settingsSection === "voice" && (
<div className="space-y-0 divide-y divide-white/[0.05]">
<SettingRow label="Text-to-Speech" sub="Read assistant replies aloud">
<Toggle on={settings.ttsEnabled} onToggle={() => updateSettings("ttsEnabled", !settings.ttsEnabled)} />
</SettingRow>
<SliderRow label="Speech Rate" sub="How fast the assistant speaks"
value={settings.ttsRate} min={0.5} max={2} step={0.1}
display={`${settings.ttsRate.toFixed(1)}×`}
onChange={(v) => updateSettings("ttsRate", v)} />
<SliderRow label="Pitch" sub="Voice pitch level"
value={settings.ttsPitch} min={0.5} max={2} step={0.1}
display={`${settings.ttsPitch.toFixed(1)}`}
onChange={(v) => updateSettings("ttsPitch", v)} />
<SettingRow label="Live Mode by Default" sub="Start in live voice mode on launch">
<Toggle on={settings.liveModeDefault} onToggle={() => updateSettings("liveModeDefault", !settings.liveModeDefault)} />
</SettingRow>
</div>
)}
{/* ─ Model ─ */}
{settingsSection === "model" && (
<div className="space-y-0 divide-y divide-white/[0.05]">
<SettingRow label="Whisper Model" sub="Speech recognition model">
<select value={settings.whisperModel}
onChange={(e) => updateSettings("whisperModel", e.target.value)}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70
outline-none transition-all cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="Xenova/whisper-tiny.en">Whisper Tiny (EN)</option>
<option value="Xenova/whisper-base.en">Whisper Base (EN)</option>
<option value="Xenova/whisper-small.en">Whisper Small (EN)</option>
<option value="Xenova/whisper-tiny">Whisper Tiny (multilingual)</option>
</select>
</SettingRow>
<SettingRow label="Quantization" sub="Lower = faster, higher = more accurate">
<select value={settings.whisperDtype}
onChange={(e) => updateSettings("whisperDtype", e.target.value as AppSettings["whisperDtype"])}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70
outline-none transition-all cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="q8">q8 (recommended)</option>
<option value="fp16">fp16</option>
<option value="fp32">fp32</option>
</select>
</SettingRow>
<SliderRow label="Chunk Length (seconds)" sub="Audio chunk size for processing"
value={settings.chunkLength} min={10} max={60} step={5}
display={`${settings.chunkLength}s`}
onChange={(v) => updateSettings("chunkLength", v)} />
<SettingRow label="Language" sub="Transcription language">
<select value={settings.language}
onChange={(e) => updateSettings("language", e.target.value)}
className="bg-white/[0.06] border rounded-lg px-3 py-1.5 text-[13px] text-white/70
outline-none transition-all cursor-pointer"
style={{ borderColor: theme.border }}>
<option value="en">English</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="ja">Japanese</option>
<option value="zh">Chinese</option>
</select>
</SettingRow>
</div>
)}
{/* ─ Appearance ─ */}
{settingsSection === "ui" && (
<div className="space-y-0 divide-y divide-white/[0.05]">
<SettingRow label="Theme" sub="Background colour scheme">
<div className="flex gap-1.5">
{(["midnight", "slate", "obsidian"] as Theme[]).map((t) => (
<button key={t} onClick={() => updateSettings("theme", t)}
className={`w-7 h-7 rounded-lg border transition-all ${settings.theme === t ? "scale-110 ring-2 ring-white/20" : "opacity-50 hover:opacity-80"}`}
style={{ background: THEMES[t].bg, borderColor: THEMES[t].border }}
title={t} />
))}
</div>
</SettingRow>
<SettingRow label="Font Size" sub="Message text size">
<div className="flex gap-1">
{(["base", "lg", "xl"] as FontSize[]).map((s) => (
<button key={s} onClick={() => updateSettings("fontSize", s)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all
${settings.fontSize === s
? "font-semibold"
: "text-white/25 hover:text-white/50 hover:bg-white/[0.06]"}`}
style={settings.fontSize === s
? { background: `${theme.accent}20`, color: theme.accent } : {}}>
{s === "base" ? "Md" : s === "lg" ? "Lg" : "XL"}
</button>
))}
</div>
</SettingRow>
<SettingRow label="Density" sub="Message padding and spacing">
<div className="flex gap-1">
{(["compact", "comfortable", "spacious"] as Density[]).map((d) => (
<button key={d} onClick={() => updateSettings("density", d)}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-all capitalize
${settings.density === d
? "font-semibold"
: "text-white/25 hover:text-white/50 hover:bg-white/[0.06]"}`}
style={settings.density === d
? { background: `${theme.accent}20`, color: theme.accent } : {}}>
{d === "compact" ? "Tight" : d === "comfortable" ? "Normal" : "Airy"}
</button>
))}
</div>
</SettingRow>
</div>
)}
{/* ─ Behaviour ─ */}
{settingsSection === "behaviour" && (
<div className="space-y-0 divide-y divide-white/[0.05]">
<SettingRow label="Auto-scroll" sub="Scroll to latest message automatically">
<Toggle on={settings.autoScroll} onToggle={() => updateSettings("autoScroll", !settings.autoScroll)} />
</SettingRow>
<SettingRow label="Send on Enter" sub="Press Enter to send (Shift+Enter for newline)">
<Toggle on={settings.sendOnEnter} onToggle={() => updateSettings("sendOnEnter", !settings.sendOnEnter)} />
</SettingRow>
<SettingRow label="Show Source Badges" sub="Show voice/typed label on messages">
<Toggle on={settings.showSourceBadge} onToggle={() => updateSettings("showSourceBadge", !settings.showSourceBadge)} />
</SettingRow>
<SliderRow label="Max Saved Sessions" sub="Older sessions are removed automatically"
value={settings.maxSessions} min={10} max={200} step={10}
display={`${settings.maxSessions}`}
onChange={(v) => updateSettings("maxSessions", v)} />
</div>
)}
{/* ─ Profile / Account ─ */}
{settingsSection === "account" && profile && (
<div className="space-y-4">
<div className="flex items-center gap-4 p-5 rounded-2xl border"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="w-14 h-14 rounded-2xl flex items-center justify-center text-3xl"
style={{ background: `${theme.accent}15` }}>
{profile.avatar}
</div>
<div>
<p className="text-base font-semibold text-white/80">{profile.name}</p>
<p className="text-[12px] text-white/25 mt-0.5">
Local profile · since {new Date(profile.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="space-y-0 divide-y divide-white/[0.05]">
<div className="py-4">
<p className="text-[13px] font-medium text-white/70 mb-2.5">Display Name</p>
<input type="text" defaultValue={profile.name}
onBlur={(e) => {
const updated = { ...profile, name: e.target.value };
setProfile(updated); S.set("nail_profile", updated);
}}
className="bg-white/[0.04] border rounded-xl px-4 py-2.5 text-[14px] text-white
outline-none focus:ring-1 transition-all w-full max-w-sm"
style={{ borderColor: theme.border }} />
</div>
<div className="py-4">
<p className="text-[13px] font-medium text-white/70 mb-2.5">Avatar</p>
<div className="flex flex-wrap gap-2">
{AVATARS.map((av) => (
<button key={av} onClick={() => {
const updated = { ...profile, avatar: av };
setProfile(updated); S.set("nail_profile", updated);
}}
className={`w-10 h-10 rounded-xl text-xl transition-all
${profile.avatar === av
? "ring-2 scale-110"
: "bg-white/[0.04] hover:bg-white/[0.08] opacity-60 hover:opacity-100"}`}
style={profile.avatar === av
? { background: `${theme.accent}20`, boxShadow: `0 0 0 2px ${theme.accent}` } : {}}>
{av}
</button>
))}
</div>
</div>
</div>
<button
onClick={() => {
S.set("nail_profile", null);
setProfile(null); setAuthStep("setup");
setSetupName(""); setSetupAvatar(AVATARS[0]);
}}
className="text-[13px] text-red-400/40 hover:text-red-400 transition-colors">
Reset profile and data
</button>
</div>
)}
{/* ─ Storage ─ */}
{settingsSection === "storage" && (
<div className="space-y-4">
{[
{ label: "Saved Sessions", value: sessions.length, max: settings.maxSessions, unit: "sessions" },
].map(({ label, value, max, unit }) => (
<div key={label} className="p-5 rounded-2xl border"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<div className="flex justify-between text-[13px] mb-3">
<span className="font-medium text-white/70">{label}</span>
<span className="text-white/30 font-mono">{value} / {max} {unit}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden bg-white/[0.06]">
<div className="h-full rounded-full transition-all"
style={{ width: `${Math.min((value / max) * 100, 100)}%`, background: theme.accent }} />
</div>
</div>
))}
<div className="p-5 rounded-2xl border space-y-3"
style={{ background: "rgba(255,255,255,0.02)", borderColor: theme.border }}>
<p className="text-[13px] font-medium text-white/70">Storage Keys</p>
{["nail_profile", "nail_settings", "nail_sessions"].map((key) => {
const raw = typeof window !== "undefined" ? localStorage.getItem(key) : null;
const size = raw ? new Blob([raw]).size : 0;
return (
<div key={key} className="flex justify-between text-[12px]">
<span className="text-white/25 font-mono">{key}</span>
<span className="text-white/40">{size > 1024 ? `${(size/1024).toFixed(1)} KB` : `${size} B`}</span>
</div>
);
})}
</div>
<button
onClick={() => {
S.set("nail_sessions", []);
setSessions([]); setOpenSessionIds([]); setActiveSessionId(null);
}}
className="text-[13px] text-red-400/40 hover:text-red-400 transition-colors">
Delete all conversation history
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}