'use client'; import { Command } from 'cmdk'; import { ReactNode, useEffect, useMemo, useRef, useState, useTransition, } from 'react'; import { PATH_ADMIN_BASELINE, PATH_ADMIN_CONFIGURATION, PATH_ADMIN_INSIGHTS, PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS, PATH_ADMIN_UPLOADS, PATH_FEED_INFERRED, PATH_GRID_INFERRED, PATH_ROOT, PATH_SIGN_IN, pathForPhoto, pathForTag, } from '../../app/paths'; import Modal from '../Modal'; import { clsx } from 'clsx/lite'; import { useDebounce } from 'use-debounce'; import Spinner from '../Spinner'; import { usePathname, 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 { searchPhotosAction } 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 { Tags, addHiddenToTags, formatTag } from '@/tag'; import { FaTag } from 'react-icons/fa'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import CommandKItem from './CommandKItem'; import { GRID_HOMEPAGE_ENABLED } from '@/app/config'; import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog'; import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; 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: Tags serverSections?: CommandKSection[] showDebugTools?: boolean footer?: string }) { const pathname = usePathname(); const { isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, hiddenPhotosCount, selectedPhotoIds, setSelectedPhotoIds, isGridHighDensity, areZoomControlsShown, arePhotosMatted, shouldShowBaselineGrid, shouldDebugImageFallbacks, setIsCommandKOpen: setIsOpen, setShouldRespondToKeyboardCommands, setShouldShowBaselineGrid, setIsGridHighDensity, setAreZoomControlsShown, setArePhotosMatted, setShouldDebugImageFallbacks, } = useAppState(); const isOpenRef = useRef(isOpen); const [isPending, startTransition] = useTransition(); const [keyPending, setKeyPending] = useState(); const shouldCloseAfterPending = useRef(false); useEffect(() => { if (!isPending) { setKeyPending(undefined); if (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); searchPhotosAction(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); }) .catch(e => { console.error(e); 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: formatTag(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 Zoom Controls', action: () => setAreZoomControlsShown?.(prev => !prev), annotation: areZoomControlsShown ? : undefined, }, { label: 'Toggle Photo Matting', action: () => setArePhotosMatted?.(prev => !prev), annotation: arePhotosMatted ? : undefined, }, { label: 'Toggle High Density Grid', action: () => setIsGridHighDensity?.(prev => !prev), annotation: isGridHighDensity ? : undefined, }, { label: 'Toggle Image Fallbacks', action: () => setShouldDebugImageFallbacks?.(prev => !prev), annotation: shouldDebugImageFallbacks ? : undefined, }, { label: 'Toggle Baseline Grid', action: () => setShouldShowBaselineGrid?.(prev => !prev), annotation: shouldShowBaselineGrid ? : undefined, }], }); } const pagesItems: CommandKItem[] = [{ label: 'Home', path: PATH_ROOT, }]; if (GRID_HOMEPAGE_ENABLED) { pagesItems.push({ label: 'Feed', path: PATH_FEED_INFERRED, }); } else { pagesItems.push({ label: 'Grid', path: PATH_GRID_INFERRED, }); } const sectionPages: CommandKSection = { heading: 'Pages', accessory: , items: pagesItems, }; 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, }, { label: 'App Insights', annotation: , path: PATH_ADMIN_INSIGHTS, }, { label: selectedPhotoIds === undefined ? 'Select Multiple Photos' : 'Exit Select Multiple Photos', annotation: , path: selectedPhotoIds === undefined ? PATH_GRID_INFERRED : undefined, action: selectedPhotoIds === undefined ? () => setSelectedPhotoIds?.([]) : () => setSelectedPhotoIds?.(undefined), }] 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 >
{DIALOG_TITLE} {DIALOG_DESCRIPTION} 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 dark:focus:border-gray-800', 'placeholder:text-gray-400/80', 'dark:placeholder:text-gray-700', 'focus:outline-hidden', isPending && 'opacity-20', )} 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, accessory, annotation, annotationAria, path, action, }) => { const key = `${heading} ${label}`; return { if (action) { action(); if (!path) { setIsOpen?.(false); } } if (path) { if (path !== pathname) { setKeyPending(key); startTransition(() => { shouldCloseAfterPending.current = true; router.push(path, { scroll: true }); }); } else { setIsOpen?.(false); } } }} accessory={accessory} annotation={annotation} annotationAria={annotationAria} loading={key === keyPending} disabled={isPending && key !== keyPending} />; })} )} {footer && !queryLive &&
{footer}
}
); }