mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
64
web/components/layout/Header.tsx
Normal file
64
web/components/layout/Header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
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();
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
|
||||
const themeIcons = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
const ThemeIcon = themeIcons[theme];
|
||||
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
|
||||
</div>
|
||||
|
||||
<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(
|
||||
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
|
||||
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
)}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Notification center */}
|
||||
<NotificationCenter />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(nextTheme)}
|
||||
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" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
189
web/components/layout/Sidebar.tsx
Normal file
189
web/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
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 } from "@/lib/utils";
|
||||
import { ChatHistory } from "./ChatHistory";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { QuickActions } from "./QuickActions";
|
||||
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
const COLLAPSED_WIDTH = 60;
|
||||
|
||||
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 {
|
||||
sidebarOpen,
|
||||
sidebarWidth,
|
||||
sidebarTab,
|
||||
toggleSidebar,
|
||||
setSidebarWidth,
|
||||
setSidebarTab,
|
||||
openSettings,
|
||||
} = useChatStore();
|
||||
|
||||
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 (
|
||||
<motion.aside
|
||||
className={cn(
|
||||
"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"
|
||||
>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
29
web/components/layout/SidebarToggle.tsx
Normal file
29
web/components/layout/SidebarToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
web/components/layout/ThemeProvider.tsx
Normal file
57
web/components/layout/ThemeProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "dark",
|
||||
setTheme: () => {},
|
||||
resolvedTheme: "dark",
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const resolve = () => {
|
||||
if (settings.theme === "system") {
|
||||
return mediaQuery.matches ? "dark" : "light";
|
||||
}
|
||||
return settings.theme;
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const resolved = resolve();
|
||||
setResolvedTheme(resolved);
|
||||
// Dark is the default; add `.light` class for light theme
|
||||
document.documentElement.classList.toggle("light", resolved === "light");
|
||||
};
|
||||
|
||||
apply();
|
||||
mediaQuery.addEventListener("change", apply);
|
||||
return () => mediaQuery.removeEventListener("change", apply);
|
||||
}, [settings.theme]);
|
||||
|
||||
const setTheme = (theme: Theme) => {
|
||||
updateSettings({ theme });
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: settings.theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
Reference in New Issue
Block a user