140 lines
4.6 KiB
JavaScript
140 lines
4.6 KiB
JavaScript
|
|
import { useRef, useState } from 'react';
|
||
|
|
import { useRouter } from 'next/router';
|
||
|
|
import Modal from './Modal';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* CommandPaletteModal — the surface that opens on Cmd+K (or Ctrl+K).
|
||
|
|
*
|
||
|
|
* From the redesign-v2 mockup's top-bar Cmd+K hint. Operator decided
|
||
|
|
* (§ 7.2 of the umbrella convoy) to ship the keyboard handler + a
|
||
|
|
* minimum-viable palette in this convoy, deferring federated search
|
||
|
|
* across cards/decks/lists to a follow-up.
|
||
|
|
*
|
||
|
|
* What it does today:
|
||
|
|
* - Single search input, auto-focused on open
|
||
|
|
* - Submit on Enter → routes to /cards?q=<query> (the closest
|
||
|
|
* existing search surface). Closes the palette.
|
||
|
|
* - 3 placeholder "Quick actions" rows below the input, each
|
||
|
|
* routing to an existing page (Dashboard, Cards, Scanner).
|
||
|
|
* - Escape closes (delegated to the underlying <Modal>).
|
||
|
|
*
|
||
|
|
* What it does NOT do (deferred):
|
||
|
|
* - Live result preview as you type
|
||
|
|
* - "Recent searches" from a backend store
|
||
|
|
* - Cross-resource ranked results (cards + decks + lists + users)
|
||
|
|
*
|
||
|
|
* Props:
|
||
|
|
* open boolean — controls visibility
|
||
|
|
* onClose () => void
|
||
|
|
*/
|
||
|
|
const QUICK_ACTIONS = [
|
||
|
|
{ label: 'Go to Dashboard', href: '/dashboard', keywords: ['home', 'dash'] },
|
||
|
|
{ label: 'Browse Cards', href: '/cards', keywords: ['catalog', 'all'] },
|
||
|
|
{ label: 'Open Scanner', href: '/scanner', keywords: ['scan', 'camera'] },
|
||
|
|
];
|
||
|
|
|
||
|
|
export default function CommandPaletteModal({ open, onClose }) {
|
||
|
|
const router = useRouter();
|
||
|
|
const inputRef = useRef(null);
|
||
|
|
// Reset query when the modal opens. We track the previous `open` and
|
||
|
|
// clear synchronously during render (per the react-hooks/set-state-
|
||
|
|
// in-effect lint rule — the same pattern used elsewhere in the
|
||
|
|
// codebase, e.g. NavigationContent's path-change resetter).
|
||
|
|
const [query, setQuery] = useState('');
|
||
|
|
const [wasOpen, setWasOpen] = useState(open);
|
||
|
|
if (open && !wasOpen) {
|
||
|
|
setWasOpen(true);
|
||
|
|
setQuery('');
|
||
|
|
} else if (!open && wasOpen) {
|
||
|
|
setWasOpen(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSubmit = (event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
const trimmed = query.trim();
|
||
|
|
if (trimmed.length === 0) return;
|
||
|
|
onClose?.();
|
||
|
|
router.push({ pathname: '/cards', query: { q: trimmed } });
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleQuickAction = (href) => {
|
||
|
|
onClose?.();
|
||
|
|
router.push(href);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Modal
|
||
|
|
open={open}
|
||
|
|
onClose={onClose}
|
||
|
|
title="Search"
|
||
|
|
description="Search cards, decks, and lists. Press Enter to search Cards."
|
||
|
|
size="md"
|
||
|
|
initialFocusRef={inputRef}
|
||
|
|
hideCloseButton
|
||
|
|
>
|
||
|
|
<form onSubmit={handleSubmit}>
|
||
|
|
<div className="relative mb-4">
|
||
|
|
<span
|
||
|
|
className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
className="h-5 w-5"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
<path
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
strokeWidth={2}
|
||
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
</span>
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
type="text"
|
||
|
|
value={query}
|
||
|
|
onChange={(event) => setQuery(event.target.value)}
|
||
|
|
placeholder="Search cards, decks, lists, users..."
|
||
|
|
aria-label="Search query"
|
||
|
|
className="w-full pl-10 pr-4 py-3 rounded-xl text-base focus:outline-none focus:ring-2"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--input-bg)',
|
||
|
|
color: 'var(--input-text)',
|
||
|
|
border: '1px solid var(--input-border)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div
|
||
|
|
className="text-xs font-semibold uppercase tracking-wide mb-2"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
Quick actions
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{QUICK_ACTIONS.map((action) => (
|
||
|
|
<button
|
||
|
|
key={action.href}
|
||
|
|
type="button"
|
||
|
|
onClick={() => handleQuickAction(action.href)}
|
||
|
|
className="w-full text-left px-3 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 nav-item-hover"
|
||
|
|
style={{
|
||
|
|
color: 'var(--text-primary)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{action.label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|