'use client'; import { Command } from 'cmdk'; import { ReactNode, SetStateAction, Dispatch, useEffect, useMemo, useRef, useState, useTransition, } from 'react'; import { PATH_ADMIN_BASELINE, PATH_ADMIN_COMPONENTS, 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, FaCircle } 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: ReactNode keywords?: string[] accessory?: ReactNode annotation?: ReactNode annotationAria?: string path?: string action?: () => void | Promise } export type CommandKSection = { heading: string accessory?: ReactNode items: CommandKItem[] } const renderToggle = ( label: string, onToggle?: Dispatch>, isEnabled?: boolean, ): CommandKItem => ({ label: `Toggle ${label}`, action: () => onToggle?.(prev => !prev), annotation: isEnabled ? : undefined, }); 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, insightIndicatorStatus, isGridHighDensity, areZoomControlsShown, arePhotosMatted, shouldShowBaselineGrid, shouldDebugImageFallbacks, shouldDebugInsights, shouldDebugRecipeOverlays, setIsCommandKOpen: setIsOpen, setShouldRespondToKeyboardCommands, setShouldShowBaselineGrid, setIsGridHighDensity, setAreZoomControlsShown, setArePhotosMatted, setShouldDebugImageFallbacks, setShouldDebugInsights, setShouldDebugRecipeOverlays, } = 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: [ renderToggle( 'Zoom Controls', setAreZoomControlsShown, areZoomControlsShown, ), renderToggle( 'Photo Matting', setArePhotosMatted, arePhotosMatted, ), renderToggle( 'High Density Grid', setIsGridHighDensity, isGridHighDensity, ), renderToggle( 'Image Fallbacks', setShouldDebugImageFallbacks, shouldDebugImageFallbacks, ), renderToggle( 'Baseline Grid', setShouldShowBaselineGrid, shouldShowBaselineGrid, ), renderToggle( 'Insights Debugging', setShouldDebugInsights, shouldDebugInsights, ), renderToggle( 'Recipe Overlays', setShouldDebugRecipeOverlays, shouldDebugRecipeOverlays, ), ], }); } 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 {insightIndicatorStatus && } , keywords: ['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, }, { label: 'Components Overview', path: PATH_ADMIN_COMPONENTS, }] : []) .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?.some(keyword => keyword.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}
}
); }