EmberClone/apps/web/src/components/CommandPalette.tsx

171 lines
7.7 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.

import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Search, Command, X, Plus, Moon, Sun, LogOut, Palette } from 'lucide-react';
interface CommandItem {
id: string;
label: string;
path?: string;
action?: () => void;
icon: React.ReactNode;
category: string;
}
export default function CommandPalette({
onQuickAdd,
toggleTheme,
onLogout
}: {
onQuickAdd?: () => void;
toggleTheme?: () => void;
onLogout?: () => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const navigate = useNavigate();
const COMMAND_ITEMS: CommandItem[] = useMemo(() => [
{ id: 'dash', label: 'Dashboard', path: '/', icon: <span className="text-blue-500">📊</span>, category: 'General' },
{ id: 'time', label: 'Time Entries', path: '/time-entries', icon: <span className="text-green-500"></span>, category: 'Tracking' },
{ id: 'cust', label: 'Customers', path: '/customers', icon: <span className="text-purple-500">👥</span>, category: 'CRM' },
{ id: 'proj', label: 'Projects', path: '/projects', icon: <span className="text-orange-500">📁</span>, category: 'CRM' },
{ id: 'cal', label: 'Calendar', path: '/calendar', icon: <span className="text-red-500">📅</span>, category: 'General' },
{ id: 'sett', label: 'Settings', path: '/settings', icon: <span className="text-gray-500"></span>, category: 'System' },
{ id: 'prof', label: 'Profile', path: '/profile', icon: <span className="text-indigo-500">👤</span>, category: 'System' },
{ id: 'add-time', label: 'Neuer Time-Entry', action: onQuickAdd, icon: <Plus className="w-4 h-4 text-green-500" />, category: 'Actions' },
{ id: 'toggle-theme', label: 'Dark/Light umschalten', action: toggleTheme, icon: <Moon className="w-4 h-4 text-zinc-500" />, category: 'Actions' },
{ id: 'change-theme', label: 'Theme wechseln', action: () => {}, icon: <Palette className="w-4 h-4 text-pink-500" />, category: 'Actions' },
{ id: 'logout', label: 'Logout', action: onLogout, icon: <LogOut className="w-4 h-4 text-red-500" />, category: 'Actions' },
], [onQuickAdd, toggleTheme, onLogout]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setIsOpen((prev) => !prev);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
const filteredItems = useMemo(() => {
return COMMAND_ITEMS.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
);
}, [query, COMMAND_ITEMS]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredItems.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length);
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
const item = filteredItems[selectedIndex];
if (item.path) {
navigate({ to: item.path });
} else if (item.action) {
item.action();
}
setIsOpen(false);
setQuery('');
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] bg-black/50 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
>
<div
className="w-full max-w-xl bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="flex items-center px-4 py-3 border-b border-zinc-200 dark:border-zinc-800">
<Search className="w-5 h-5 text-zinc-400 mr-3" />
<input
autoFocus
className="flex-1 bg-transparent border-none outline-none text-zinc-900 dark:text-zinc-100 placeholder-zinc-500"
placeholder="Search commands..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500">
<Command className="w-3 h-3" /> K
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
{filteredItems.length > 0 ? (
<div className="space-y-6 p-1">
{['Actions', 'General', 'Tracking', 'CRM', 'System'].map(cat => {
const catItems = filteredItems.filter(i => i.category === cat);
if (catItems.length === 0) return null;
return (
<div key={cat}>
<div className="px-3 py-1 text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">
{cat}
</div>
<div className="mt-1 space-y-1">
{catItems.map((item) => {
const isSelected = filteredItems[selectedIndex]?.id === item.id;
return (
<div
key={item.id}
className={`flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer transition-colors ${
isSelected
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800/50'
}`}
onClick={() => {
if (item.path) navigate({ to: item.path });
if (item.action) item.action();
setIsOpen(false);
setQuery('');
}}
>
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-5 h-5">{item.icon}</span>
<span className="text-sm font-medium">{item.label}</span>
</div>
{item.path && <span className="text-[10px] opacity-50">{item.path}</span>}
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="py-12 text-center text-zinc-500 text-sm">
No results found for "{query}"
</div>
)}
</div>
<div className="px-4 py-2 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 flex justify-between items-center">
<span className="text-[10px] text-zinc-400">Use <kbd className="px-1 py-0.5 rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700"></kbd> to navigate and <kbd className="px-1 py-0.5 rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">Enter</kbd> to select</span>
<button onClick={() => setIsOpen(false)} className="p-1 hover:bg-zinc-200 dark:hover:bg-zinc-800 rounded">
<X className="w-4 h-4 text-zinc-400" />
</button>
</div>
</div>
</div>
);
}