1207 lines
58 KiB
TypeScript
1207 lines
58 KiB
TypeScript
// 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>
|
||
);
|
||
}
|