218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { Command } from 'cmdk';
|
|
import { ReactNode, useEffect, useState } from 'react';
|
|
import Modal from './Modal';
|
|
import { clsx } from 'clsx/lite';
|
|
import { useDebounce } from 'use-debounce';
|
|
import Spinner from './Spinner';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useTheme } from 'next-themes';
|
|
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
|
import { IoInvertModeSharp } from 'react-icons/io5';
|
|
|
|
const LISTENER_KEYDOWN = 'keydown';
|
|
const MINIMUM_QUERY_LENGTH = 2;
|
|
|
|
export type CommandKSection = {
|
|
heading: string
|
|
accessory?: ReactNode
|
|
items: {
|
|
label: string
|
|
annotation?: ReactNode
|
|
annotationAria?: string
|
|
accessory?: ReactNode
|
|
path?: string
|
|
action?: () => void
|
|
}[]
|
|
}
|
|
|
|
export default function CommandKClient({
|
|
onQueryChange,
|
|
sections = [],
|
|
}: {
|
|
onQueryChange?: (query: string) => Promise<CommandKSection[]>
|
|
sections?: CommandKSection[]
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [queryRaw, setQueryRaw] = useState('');
|
|
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true });
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
|
|
|
|
const { setTheme } = useTheme();
|
|
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
setIsOpen((open) => !open);
|
|
}
|
|
};
|
|
document.addEventListener(LISTENER_KEYDOWN, down);
|
|
return () => document.removeEventListener(LISTENER_KEYDOWN, down);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH) {
|
|
setIsLoading(true);
|
|
onQueryChange?.(queryDebounced).then(querySections => {
|
|
setQueriedSections(querySections);
|
|
setIsLoading(false);
|
|
});
|
|
}
|
|
}, [queryDebounced, onQueryChange]);
|
|
|
|
useEffect(() => {
|
|
if (queryRaw === '') {
|
|
setQueriedSections([]);
|
|
} else if (queryRaw.length >= MINIMUM_QUERY_LENGTH) {
|
|
setIsLoading(true);
|
|
}
|
|
}, [queryRaw]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setQueryRaw('');
|
|
setQueriedSections([]);
|
|
setIsLoading(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const sectionTheme: CommandKSection = {
|
|
heading: 'Theme',
|
|
accessory: <IoInvertModeSharp
|
|
size={14}
|
|
className="translate-y-[0.5px] translate-x-[-1px]"
|
|
/>,
|
|
items: [{
|
|
label: 'Use System',
|
|
annotation: <BiDesktop />,
|
|
action: () => setTheme('system'),
|
|
}, {
|
|
label: 'Light Mode',
|
|
annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
|
|
action: () => setTheme('light'),
|
|
}, {
|
|
label: 'Dark Mode',
|
|
annotation: <BiMoon className="translate-x-[1px]" />,
|
|
action: () => setTheme('dark'),
|
|
}],
|
|
};
|
|
|
|
return (
|
|
<Command.Dialog
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
label="Global Command Menu"
|
|
filter={(value, search) =>
|
|
value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
|
|
loop
|
|
>
|
|
<Modal
|
|
anchor='top'
|
|
onClose={() => setIsOpen(false)}
|
|
fast
|
|
>
|
|
<div className="space-y-1.5">
|
|
<div className="relative">
|
|
<Command.Input
|
|
onChangeCapture={(e) => setQueryRaw(e.currentTarget.value)}
|
|
className={clsx(
|
|
'w-full',
|
|
'focus:ring-0',
|
|
'!border-gray-200 dark:!border-gray-800',
|
|
'focus:border-gray-200 focus:dark:border-gray-800',
|
|
'placeholder:text-gray-400/80',
|
|
'placeholder:dark:text-gray-700',
|
|
)}
|
|
style={{ paddingRight: '2rem' }}
|
|
placeholder="Search photos, views, settings ..."
|
|
/>
|
|
{isLoading &&
|
|
<span className="absolute top-2.5 right-3">
|
|
<Spinner size={16} />
|
|
</span>}
|
|
</div>
|
|
<Command.List className="relative max-h-72 overflow-y-scroll">
|
|
<Command.Empty className="mt-1 pl-3 text-dim">
|
|
{isLoading ? 'Searching ...' : 'No results found'}
|
|
</Command.Empty>
|
|
{queriedSections
|
|
.concat(sections)
|
|
.concat(sectionTheme)
|
|
.filter(({ items }) => items.length > 0)
|
|
.map(({ heading, accessory, items }) =>
|
|
<Command.Group
|
|
key={heading}
|
|
heading={<div className={clsx(
|
|
'flex items-center',
|
|
'px-2',
|
|
)}>
|
|
{accessory &&
|
|
<div className="w-5">{accessory}</div>}
|
|
{heading}
|
|
</div>}
|
|
className={clsx(
|
|
'uppercase',
|
|
'select-none',
|
|
'[&>*:first-child]:py-1',
|
|
'[&>*:first-child]:font-medium',
|
|
'[&>*:first-child]:text-dim',
|
|
'[&>*:first-child]:text-xs',
|
|
'[&>*:first-child]:tracking-wider',
|
|
)}
|
|
>
|
|
{items.map(({
|
|
accessory,
|
|
label,
|
|
annotation,
|
|
annotationAria,
|
|
path,
|
|
action,
|
|
}) =>
|
|
<Command.Item
|
|
key={`${heading}-${label}`}
|
|
value={`${heading}-${label}`}
|
|
className={clsx(
|
|
'px-2',
|
|
accessory ? 'py-2' : 'py-1',
|
|
'rounded-md cursor-pointer tracking-wide',
|
|
'data-[selected=true]:bg-gray-100',
|
|
'data-[selected=true]:dark:bg-gray-900/75',
|
|
)}
|
|
onSelect={() => {
|
|
setIsOpen(false);
|
|
action?.();
|
|
if (path) {
|
|
router.push(path);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
{accessory}
|
|
<span className="grow text-ellipsis truncate">
|
|
{label}
|
|
</span>
|
|
{annotation &&
|
|
<span
|
|
className="text-dim whitespace-nowrap"
|
|
aria-label={annotationAria}
|
|
>
|
|
<span aria-hidden={Boolean(annotationAria)}>
|
|
{annotation}
|
|
</span>
|
|
</span>}
|
|
</div>
|
|
</Command.Item>)}
|
|
</Command.Group>)}
|
|
</Command.List>
|
|
</div>
|
|
</Modal>
|
|
</Command.Dialog>
|
|
);
|
|
}
|