feat: implement API key authentication and user session management
Some checks failed
CI / Typecheck & Lint (push) Has been cancelled

This commit is contained in:
nirholas
2026-03-31 12:43:05 +00:00
parent da6c5e1ed7
commit 3a854557e0
145 changed files with 34693 additions and 690 deletions

View File

@@ -5,6 +5,7 @@ import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
export function Header() {
const { theme, setTheme } = useTheme();
@@ -27,7 +28,11 @@ export function Header() {
<div className="flex items-center gap-2">
{/* Model selector */}
<label htmlFor="model-select" className="sr-only">
Model
</label>
<select
id="model-select"
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
@@ -42,13 +47,16 @@ export function Header() {
))}
</select>
{/* Notification center */}
<NotificationCenter />
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title={`Switch to ${nextTheme} theme`}
aria-label={`Switch to ${nextTheme} theme`}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<ThemeIcon className="w-4 h-4" />
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>

View File

@@ -1,93 +1,189 @@
"use client";
import { useState } from "react";
import { Plus, MessageSquare, Trash2, Settings } from "lucide-react";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, FolderOpen, Settings, ChevronLeft, ChevronRight } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn, formatDate, truncate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { ChatHistory } from "./ChatHistory";
import { FileExplorer } from "./FileExplorer";
import { QuickActions } from "./QuickActions";
interface SidebarProps {
className?: string;
}
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
const COLLAPSED_WIDTH = 60;
export function Sidebar({ className }: SidebarProps) {
type SidebarTab = "chats" | "history" | "files" | "settings";
const TABS: Array<{ id: SidebarTab; icon: React.ElementType; label: string }> = [
{ id: "chats", icon: MessageSquare, label: "Chats" },
{ id: "files", icon: FolderOpen, label: "Files" },
{ id: "settings", icon: Settings, label: "Settings" },
];
export function Sidebar() {
const {
conversations,
activeConversationId,
createConversation,
setActiveConversation,
deleteConversation,
sidebarOpen,
sidebarWidth,
sidebarTab,
toggleSidebar,
setSidebarWidth,
setSidebarTab,
openSettings,
} = useChatStore();
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
resizeRef.current = { startX: e.clientX, startWidth: sidebarWidth };
setIsResizing(true);
},
[sidebarWidth]
);
useEffect(() => {
if (!isResizing) return;
const onMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
const delta = e.clientX - resizeRef.current.startX;
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.startWidth + delta));
setSidebarWidth(next);
};
const onUp = () => setIsResizing(false);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [isResizing, setSidebarWidth]);
// Global keyboard shortcut: Cmd/Ctrl+B
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
e.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggleSidebar]);
const handleTabClick = (id: SidebarTab) => {
if (id === "settings") {
openSettings();
return;
}
if (!sidebarOpen) toggleSidebar();
setSidebarTab(id);
};
return (
<aside
<motion.aside
className={cn(
"flex flex-col h-full bg-surface-900 border-r border-surface-800 w-64",
className
"hidden md:flex flex-col h-full bg-surface-900 border-r border-surface-800",
"relative flex-shrink-0 z-20",
isResizing && "select-none"
)}
animate={{ width: sidebarOpen ? sidebarWidth : COLLAPSED_WIDTH }}
transition={{ duration: 0.2, ease: "easeInOut" }}
aria-label="Navigation sidebar"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<span className="text-sm font-semibold text-surface-100">Claude Code</span>
<button
onClick={createConversation}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title="New conversation"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Conversation list */}
<nav className="flex-1 overflow-y-auto py-2">
{conversations.length === 0 ? (
<div className="px-4 py-8 text-center text-surface-500 text-sm">
No conversations yet
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
className={cn(
"group relative flex items-center px-3 py-2 mx-2 rounded-md cursor-pointer",
"hover:bg-surface-800 transition-colors",
activeConversationId === conv.id && "bg-surface-800"
)}
onClick={() => setActiveConversation(conv.id)}
onMouseEnter={() => setHoveredId(conv.id)}
onMouseLeave={() => setHoveredId(null)}
>
<MessageSquare className="w-3.5 h-3.5 text-surface-500 flex-shrink-0 mr-2" />
<div className="flex-1 min-w-0">
<p className="text-sm text-surface-200 truncate">
{truncate(conv.title, 30)}
</p>
<p className="text-xs text-surface-500">{formatDate(conv.updatedAt)}</p>
</div>
{hoveredId === conv.id && (
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1 rounded text-surface-500 hover:text-red-400 hover:bg-surface-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))
{/* Top bar: app name + tabs + collapse toggle */}
<div
className={cn(
"flex border-b border-surface-800 flex-shrink-0",
sidebarOpen ? "flex-row items-center" : "flex-col items-center py-2 gap-1"
)}
>
{sidebarOpen && (
<span className="flex-1 text-sm font-semibold text-surface-100 px-4 py-3 truncate">
Claude Code
</span>
)}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-surface-800">
<button className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-100 transition-colors w-full">
<Settings className="w-4 h-4" />
<span>Settings</span>
<div
className={cn(
"flex",
sidebarOpen
? "flex-row items-center gap-0.5 pr-1 py-1.5"
: "flex-col w-full px-1.5 gap-0.5"
)}
>
{TABS.map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => handleTabClick(id)}
title={label}
aria-label={label}
className={cn(
"flex items-center gap-2 rounded-md text-xs font-medium transition-colors",
sidebarOpen ? "px-2.5 py-1.5" : "w-full justify-center px-0 py-2",
sidebarOpen && sidebarTab === id && id !== "settings"
? "bg-surface-800 text-surface-100"
: "text-surface-500 hover:text-surface-300 hover:bg-surface-800/60"
)}
>
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
{sidebarOpen && <span>{label}</span>}
</button>
))}
</div>
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Collapse sidebar (⌘B)" : "Expand sidebar (⌘B)"}
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
className={cn(
"p-2 rounded-md text-surface-500 hover:text-surface-300 hover:bg-surface-800/60 transition-colors",
sidebarOpen ? "mr-1" : "my-0.5"
)}
>
{sidebarOpen ? (
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronRight className="w-4 h-4" aria-hidden="true" />
)}
</button>
</div>
</aside>
{/* Tab content */}
<AnimatePresence mode="wait">
{sidebarOpen && (
<motion.div
key={sidebarTab}
className="flex-1 flex flex-col min-h-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{(sidebarTab === "chats" || sidebarTab === "history") && <ChatHistory />}
{sidebarTab === "files" && <FileExplorer />}
</motion.div>
)}
</AnimatePresence>
{sidebarOpen && <QuickActions />}
{/* Drag-to-resize handle */}
{sidebarOpen && (
<div
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10 transition-colors",
"hover:bg-brand-500/40",
isResizing && "bg-brand-500/60"
)}
/>
)}
</motion.aside>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { PanelLeft } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn } from "@/lib/utils";
interface SidebarToggleProps {
className?: string;
}
export function SidebarToggle({ className }: SidebarToggleProps) {
const { sidebarOpen, toggleSidebar } = useChatStore();
return (
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Close sidebar (⌘B)" : "Open sidebar (⌘B)"}
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
aria-expanded={sidebarOpen}
className={cn(
"p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
className
)}
>
<PanelLeft className="w-4 h-4" aria-hidden="true" />
</button>
);
}

View File

@@ -34,7 +34,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const apply = () => {
const resolved = resolve();
setResolvedTheme(resolved);
document.documentElement.classList.toggle("dark", resolved === "dark");
// Dark is the default; add `.light` class for light theme
document.documentElement.classList.toggle("light", resolved === "light");
};
apply();