diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..9e5c287 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api'; +import type { Client } from '@/types'; +import { + Search, LayoutDashboard, Users, Calendar, Mail, Settings, + Network, BarChart3, Shield, User, ArrowRight, Command, +} from 'lucide-react'; +import { cn, getInitials } from '@/lib/utils'; + +interface CommandItem { + id: string; + type: 'page' | 'client' | 'action'; + icon: typeof Users; + title: string; + subtitle?: string; + path?: string; + action?: () => void; +} + +const pages: CommandItem[] = [ + { id: 'page-dashboard', type: 'page', icon: LayoutDashboard, title: 'Dashboard', path: '/' }, + { id: 'page-clients', type: 'page', icon: Users, title: 'Clients', path: '/clients' }, + { id: 'page-events', type: 'page', icon: Calendar, title: 'Events', path: '/events' }, + { id: 'page-emails', type: 'page', icon: Mail, title: 'Emails', path: '/emails' }, + { id: 'page-network', type: 'page', icon: Network, title: 'Network', path: '/network' }, + { id: 'page-reports', type: 'page', icon: BarChart3, title: 'Reports', path: '/reports' }, + { id: 'page-settings', type: 'page', icon: Settings, title: 'Settings', path: '/settings' }, + { id: 'page-admin', type: 'page', icon: Shield, title: 'Admin', path: '/admin' }, +]; + +export default function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [clients, setClients] = useState([]); + const inputRef = useRef(null); + const listRef = useRef(null); + const navigate = useNavigate(); + + // Load clients when opened + useEffect(() => { + if (open && clients.length === 0) { + api.getClients().then(setClients).catch(() => {}); + } + }, [open, clients.length]); + + // Keyboard shortcut + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + if (e.key === 'Escape' && open) { + setOpen(false); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open]); + + // Focus input when opened + useEffect(() => { + if (open) { + setQuery(''); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + // Build filtered results + const results = useMemo(() => { + const items: CommandItem[] = []; + const q = query.toLowerCase().trim(); + + if (!q) { + // Show pages + recent clients + items.push(...pages); + const recentClients = clients.slice(0, 5).map((c): CommandItem => ({ + id: `client-${c.id}`, + type: 'client', + icon: User, + title: `${c.firstName} ${c.lastName}`, + subtitle: [c.company, c.email].filter(Boolean).join(' · '), + path: `/clients/${c.id}`, + })); + items.push(...recentClients); + } else { + // Filter pages + const matchedPages = pages.filter( + (p) => p.title.toLowerCase().includes(q) + ); + items.push(...matchedPages); + + // Filter clients + const matchedClients = clients + .filter((c) => + `${c.firstName} ${c.lastName}`.toLowerCase().includes(q) || + c.email?.toLowerCase().includes(q) || + c.company?.toLowerCase().includes(q) || + c.industry?.toLowerCase().includes(q) + ) + .slice(0, 8) + .map((c): CommandItem => ({ + id: `client-${c.id}`, + type: 'client', + icon: User, + title: `${c.firstName} ${c.lastName}`, + subtitle: [c.company, c.email].filter(Boolean).join(' · '), + path: `/clients/${c.id}`, + })); + items.push(...matchedClients); + + // Add "New Client" action if searching + items.push({ + id: 'action-new-client', + type: 'action', + icon: Users, + title: 'Add New Client', + subtitle: 'Create a new client profile', + path: '/clients', + action: () => navigate('/clients', { state: { openCreate: true } }), + }); + } + + return items; + }, [query, clients, navigate]); + + // Reset active index when results change + useEffect(() => { + setActiveIndex(0); + }, [results.length]); + + const handleSelect = useCallback((item: CommandItem) => { + setOpen(false); + if (item.action) { + item.action(); + } else if (item.path) { + navigate(item.path); + } + }, [navigate]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && results[activeIndex]) { + e.preventDefault(); + handleSelect(results[activeIndex]); + } + }; + + // Scroll active item into view + useEffect(() => { + if (!listRef.current) return; + const activeEl = listRef.current.children[activeIndex] as HTMLElement; + activeEl?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex]); + + if (!open) return null; + + const pageItems = results.filter((r) => r.type === 'page'); + const clientItems = results.filter((r) => r.type === 'client'); + const actionItems = results.filter((r) => r.type === 'action'); + + let flatIndex = -1; + + const renderItem = (item: CommandItem) => { + flatIndex++; + const idx = flatIndex; + const Icon = item.icon; + return ( + + ); + }; + + return ( + <> + {/* Backdrop */} +
setOpen(false)} + /> + + {/* Palette */} +
+
+ {/* Search input */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search clients, pages, actions..." + className="flex-1 text-sm bg-transparent outline-none text-slate-900 dark:text-slate-100 placeholder-slate-400" + /> + + ESC + +
+ + {/* Results */} +
+ {results.length === 0 ? ( +
+ No results found +
+ ) : ( + <> + {pageItems.length > 0 && ( + <> +
+ Pages +
+ {pageItems.map(renderItem)} + + )} + {clientItems.length > 0 && ( + <> +
+ Clients +
+ {clientItems.map(renderItem)} + + )} + {actionItems.length > 0 && ( + <> +
+ Actions +
+ {actionItems.map(renderItem)} + + )} + + )} +
+ + {/* Footer */} +
+ + ↑↓ navigate + + + select + + + esc close + +
+
+
+ + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c0cb028..3577997 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -4,9 +4,11 @@ import { useAuthStore } from '@/stores/auth'; import { cn } from '@/lib/utils'; import { LayoutDashboard, Users, Calendar, Mail, Settings, Shield, - LogOut, Menu, X, ChevronLeft, Network, BarChart3, + LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, } from 'lucide-react'; import NotificationBell from './NotificationBell'; +import CommandPalette from './CommandPalette'; +import ThemeToggle from './ThemeToggle'; const baseNavItems = [ { path: '/', label: 'Dashboard', icon: LayoutDashboard }, @@ -32,7 +34,8 @@ export default function Layout() { }; return ( -
+
+ {/* Mobile overlay */} {mobileOpen && (
{/* Logo */}
- {!collapsed && NetworkCRM} + {!collapsed && NetworkCRM}
{/* Navigation */} @@ -72,8 +75,8 @@ export default function Layout() { className={cn( 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors', isActive - ? 'bg-blue-50 text-blue-700' - : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900' + ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' + : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-200' )} > @@ -86,28 +89,28 @@ export default function Layout() { {/* Collapse button (desktop) */} {/* User / Logout */}
-
+
{user?.name?.[0]?.toUpperCase() || 'U'}
{!collapsed && (
-

{user?.name}

-

{user?.email}

+

{user?.name}

+

{user?.email}

)} -
- + {/* Search trigger */} + +
+ + +
{/* Content */} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 3001eee..093eb12 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -26,17 +26,17 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' }:
-
-

{title}

+
+

{title}

diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..226e925 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,33 @@ +import { Sun, Moon, Monitor } from 'lucide-react'; +import { useTheme } from '@/hooks/useTheme'; +import { cn } from '@/lib/utils'; + +export default function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const options = [ + { value: 'light' as const, icon: Sun, label: 'Light' }, + { value: 'dark' as const, icon: Moon, label: 'Dark' }, + { value: 'system' as const, icon: Monitor, label: 'System' }, + ]; + + return ( +
+ {options.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ); +} diff --git a/src/hooks/usePinnedClients.ts b/src/hooks/usePinnedClients.ts new file mode 100644 index 0000000..5970e81 --- /dev/null +++ b/src/hooks/usePinnedClients.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from 'react'; + +const STORAGE_KEY = 'network-pinned-clients'; + +function getStored(): string[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + } catch { + return []; + } +} + +export function usePinnedClients() { + const [pinnedIds, setPinnedIds] = useState(getStored); + + const togglePin = useCallback((clientId: string) => { + setPinnedIds((prev) => { + const next = prev.includes(clientId) + ? prev.filter((id) => id !== clientId) + : [...prev, clientId]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const isPinned = useCallback((clientId: string) => { + return pinnedIds.includes(clientId); + }, [pinnedIds]); + + return { pinnedIds, togglePin, isPinned }; +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..77807c8 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,43 @@ +import { useEffect, useState, useCallback } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +const STORAGE_KEY = 'network-theme'; + +function getSystemTheme(): 'light' | 'dark' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function applyTheme(theme: Theme) { + const resolved = theme === 'system' ? getSystemTheme() : theme; + document.documentElement.classList.toggle('dark', resolved === 'dark'); +} + +export function useTheme() { + const [theme, setThemeState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + return stored || 'system'; + }); + + const setTheme = useCallback((newTheme: Theme) => { + setThemeState(newTheme); + localStorage.setItem(STORAGE_KEY, newTheme); + applyTheme(newTheme); + }, []); + + // Apply on mount and watch system changes + useEffect(() => { + applyTheme(theme); + + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => { + if (theme === 'system') applyTheme('system'); + }; + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [theme]); + + const resolvedTheme = theme === 'system' ? getSystemTheme() : theme; + + return { theme, setTheme, resolvedTheme }; +} diff --git a/src/index.css b/src/index.css index 9caa13e..b62c980 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,7 @@ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + :root { --color-primary: #2563eb; --color-primary-dark: #1d4ed8; @@ -15,6 +17,12 @@ body { color: #0f172a; } +.dark body, +html.dark body { + background-color: #0f172a; + color: #e2e8f0; +} + * { box-sizing: border-box; } @@ -51,3 +59,12 @@ body { .animate-spin { animation: spin 1s linear infinite; } + +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.animate-slide-up { + animation: slideUp 0.15s ease-out; +} diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index 7c950ce..43efc69 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -6,8 +6,9 @@ import type { Event, Email, ActivityItem } from '@/types'; import { ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2, Briefcase, Gift, Heart, Star, Users, Calendar, Send, - CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, + CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, Pin, } from 'lucide-react'; +import { usePinnedClients } from '@/hooks/usePinnedClients'; import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils'; import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge'; import { PageLoader } from '@/components/LoadingSpinner'; @@ -26,6 +27,7 @@ export default function ClientDetailPage() { const [showEdit, setShowEdit] = useState(false); const [showCompose, setShowCompose] = useState(false); const [deleting, setDeleting] = useState(false); + const { togglePin, isPinned } = usePinnedClients(); useEffect(() => { if (id) { @@ -71,7 +73,7 @@ export default function ClientDetailPage() {
{/* Header */}
-
@@ -80,11 +82,11 @@ export default function ClientDetailPage() { {getInitials(client.firstName, client.lastName)}
-

+

{client.firstName} {client.lastName}

{client.company && ( -

+

{client.role ? `${client.role} at ` : ''}{client.company}

)} @@ -105,7 +107,14 @@ export default function ClientDetailPage() { Generate Email - +
{/* Tabs */} -
+
{tabs.map(({ key, label, count, icon: Icon }) => ( ))} @@ -142,8 +151,8 @@ export default function ClientDetailPage() { {activeTab === 'info' && (
{/* Contact Info */} -
-

Contact Information

+
+

Contact Information

{client.email && (
@@ -183,8 +192,8 @@ export default function ClientDetailPage() {
{/* Personal */} -
-

Personal Details

+
+

Personal Details

{client.birthday && (
@@ -223,16 +232,16 @@ export default function ClientDetailPage() { {/* Notes */} {client.notes && ( -
-

Notes

-

{client.notes}

+
+

Notes

+

{client.notes}

)}
)} {activeTab === 'activity' && ( -
+
{activities.length === 0 ? (

No activity recorded yet

) : ( @@ -275,7 +284,7 @@ export default function ClientDetailPage() { )} {activeTab === 'events' && ( -
+
{events.length === 0 ? (

No events for this client

) : ( @@ -293,7 +302,7 @@ export default function ClientDetailPage() { )} {activeTab === 'emails' && ( -
+
{emails.length === 0 ? (

No emails for this client

) : ( diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index fbcc805..9566653 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -71,13 +71,13 @@ export default function ClientsPage() {
-

Clients

-

{clients.length} contacts in your network

+

Clients

+

{clients.length} contacts in your network

)) )} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 01c411e..ea1eec7 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -28,41 +28,41 @@ export default function LoginPage() { }; return ( -
+
{/* Logo */}
-

NetworkCRM

-

Sign in to manage your network

+

NetworkCRM

+

Sign in to manage your network

{/* Card */} -
+
{error && ( -
+
{error}
)}
- + setEmail(e.target.value)} required - className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" + className="w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" placeholder="you@example.com" />
- + setPassword(e.target.value)} required - className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" + className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow" placeholder="••••••••" />