'use client'; import { Command } from 'cmdk'; import { ReactNode, useEffect, useMemo, useRef, useState, useTransition, } from 'react'; import { PATH_ADMIN_BASELINE, PATH_ADMIN_CONFIGURATION, PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS, PATH_ADMIN_UPLOADS, PATH_SIGN_IN, pathForPhoto, pathForTag, } from '../site/paths'; 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'; import { useAppState } from '@/state/AppState'; import { queryPhotosByTitleAction } from '@/photo/actions'; import { RiToolsFill } from 'react-icons/ri'; import { BiLockAlt, BiSolidUser } from 'react-icons/bi'; import { HiDocumentText } from 'react-icons/hi'; import { signOutAndRedirectAction } from '@/auth/actions'; import { TbPhoto } from 'react-icons/tb'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoSmall from '@/photo/PhotoSmall'; import { FaCheck } from 'react-icons/fa6'; import { TagsWithMeta, addHiddenToTags } from '@/tag'; import { FaTag } from 'react-icons/fa'; import { formatCount, formatCountDescriptive } from '@/utility/string'; const LISTENER_KEYDOWN = 'keydown'; const MINIMUM_QUERY_LENGTH = 2; type CommandKItem = { label: string keywords?: string[] accessory?: ReactNode annotation?: ReactNode annotationAria?: string path?: string action?: () => void | Promise } export type CommandKSection = { heading: string accessory?: ReactNode items: CommandKItem[] } export default function CommandKClient({ tags, serverSections = [], showDebugTools, footer, }: { tags: TagsWithMeta serverSections?: CommandKSection[] showDebugTools?: boolean footer?: string }) { const { isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, hiddenPhotosCount, arePhotosMatted, shouldShowBaselineGrid, shouldDebugImageFallbacks, setIsCommandKOpen: setIsOpen, setShouldRespondToKeyboardCommands, setShouldShowBaselineGrid, setArePhotosMatted, setShouldDebugImageFallbacks, } = useAppState(); const isOpenRef = useRef(isOpen); const [isPending, startTransition] = useTransition(); const shouldCloseAfterPending = useRef(false); useEffect(() => { if (!isPending && shouldCloseAfterPending.current) { setIsOpen?.(false); shouldCloseAfterPending.current = false; } }, [isPending, setIsOpen]); // Raw query values const [queryLiveRaw, setQueryLive] = useState(''); const [queryDebouncedRaw] = useDebounce(queryLiveRaw, 500, { trailing: true }); const isPlaceholderVisible = queryLiveRaw === ''; // Parameterized query values const queryLive = useMemo(() => queryLiveRaw.trim().toLocaleLowerCase(), [queryLiveRaw]); const queryDebounced = useMemo(() => queryDebouncedRaw.trim().toLocaleLowerCase(), [queryDebouncedRaw]); const [isLoading, setIsLoading] = useState(false); const [queriedSections, setQueriedSections] = useState([]); const { setTheme } = useTheme(); const router = useRouter(); useEffect(() => { isOpenRef.current = isOpen; }, [isOpen]); 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); }, [setIsOpen]); useEffect(() => { if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) { setIsLoading(true); queryPhotosByTitleAction(queryDebounced).then(photos => { if (isOpenRef.current) { setQueriedSections(photos.length > 0 ? [{ heading: 'Photos', accessory: , items: photos.map(photo => ({ label: titleForPhoto(photo), keywords: getKeywordsForPhoto(photo), annotation: , accessory: , path: pathForPhoto(photo), })), }] : []); } else { // Ignore stale requests that come in after dialog is closed setQueriedSections([]); } setIsLoading(false); }); } }, [queryDebounced, isPending]); useEffect(() => { if (queryLive === '') { setQueriedSections([]); setIsLoading(false); } else if (queryLive.length >= MINIMUM_QUERY_LENGTH) { setIsLoading(true); } }, [queryLive]); useEffect(() => { if (isOpen) { setShouldRespondToKeyboardCommands?.(false); } else if (!isOpen) { setQueryLive(''); setQueriedSections([]); setIsLoading(false); setTimeout(() => setShouldRespondToKeyboardCommands?.(true), 500); } }, [isOpen, setShouldRespondToKeyboardCommands]); const tagsIncludingHidden = useMemo(() => addHiddenToTags(tags, hiddenPhotosCount) , [tags, hiddenPhotosCount]); const SECTION_TAGS: CommandKSection = { heading: 'Tags', accessory: , items: tagsIncludingHidden.map(({ tag, count }) => ({ label: tag, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForTag(tag), })), }; const clientSections: CommandKSection[] = [{ heading: 'Theme', accessory: , items: [{ label: 'Use System', annotation: , action: () => setTheme('system'), }, { label: 'Light Mode', annotation: , action: () => setTheme('light'), }, { label: 'Dark Mode', annotation: , action: () => setTheme('dark'), }], }]; if (isUserSignedIn && showDebugTools) { clientSections.push({ heading: 'Debug Tools', accessory: , items: [{ label: 'Toggle Photo Matting', action: () => setArePhotosMatted?.(prev => !prev), annotation: arePhotosMatted ? : undefined, }, { label: 'Toggle Image Fallback', action: () => setShouldDebugImageFallbacks?.(prev => !prev), annotation: shouldDebugImageFallbacks ? : undefined, }, { label: 'Toggle Baseline Grid', action: () => setShouldShowBaselineGrid?.(prev => !prev), annotation: shouldShowBaselineGrid ? : undefined, }], }); } const sectionPages: CommandKSection = { heading: 'Pages', accessory: , items: ([{ label: 'Home', path: '/', }, { label: 'Grid', path:'/grid', }]), }; const adminSection: CommandKSection = { heading: 'Admin', accessory: , items: isUserSignedIn ? ([{ label: 'Manage Photos', annotation: , path: PATH_ADMIN_PHOTOS, }, { label: 'Manage Uploads', annotation: , path: PATH_ADMIN_UPLOADS, }, { label: 'Manage Tags', annotation: , path: PATH_ADMIN_TAGS, }, { label: 'App Config', annotation: , path: PATH_ADMIN_CONFIGURATION, }] as CommandKItem[]) .concat(showDebugTools ? [{ label: 'Baseline Overview', path: PATH_ADMIN_BASELINE, }] : []) .concat({ label: 'Sign Out', action: () => { signOutAndRedirectAction().then(() => setUserEmail?.(undefined)); }, }) : [{ label: 'Sign In', path: PATH_SIGN_IN, }], }; return ( { const searchFormatted = search.trim().toLocaleLowerCase(); return ( value.toLocaleLowerCase().includes(searchFormatted) || keywords?.includes(searchFormatted) ) ? 1 : 0 ; }} loop > setIsOpen?.(false)} fast >
setQueryLive(e.currentTarget.value)} className={clsx( 'w-full !min-w-0', 'focus:ring-0', isPlaceholderVisible || isLoading && '!pr-8', '!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', )} placeholder="Search photos, views, settings ..." disabled={isPending} /> {isLoading && !isPending && }
{isLoading ? 'Searching ...' : 'No results found'} {queriedSections .concat(SECTION_TAGS) .concat(serverSections) .concat(sectionPages) .concat(adminSection) .concat(clientSections) .filter(({ items }) => items.length > 0) .map(({ heading, accessory, items }) => {accessory &&
{accessory}
} {heading}
} 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(({ label, keywords, annotation, annotationAria, accessory, path, action, }) => { if (path) { startTransition(() => { shouldCloseAfterPending.current = true; router.push(path, { scroll: true }); }); } else { setIsOpen?.(false); action?.(); } }} >
{accessory} {label} {annotation && {annotation} }
)} )} {footer && !queryLive &&
{footer}
}
); }