'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_RECIPES, PATH_ADMIN_TAGS, PATH_ADMIN_UPLOADS, PATH_FEED_INFERRED, PATH_GRID_INFERRED, PATH_SIGN_IN, pathForCamera, pathForFilm, pathForFocalLength, pathForLens, pathForPhoto, pathForRecipe, pathForTag, } from '../app/paths'; import Modal from '../components/Modal'; import { clsx } from 'clsx/lite'; import { useDebounce } from 'use-debounce'; import Spinner from '../components/Spinner'; import { usePathname, useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { BiDesktop, BiLockAlt, 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 { BiSolidUser } from 'react-icons/bi'; import { HiDocumentText } from 'react-icons/hi'; import { signOutAction } from '@/auth/actions'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoSmall from '@/photo/PhotoSmall'; import { FaCheck } from 'react-icons/fa6'; import { addHiddenToTags, formatTag } from '@/tag'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import CommandKItem from './CommandKItem'; import { CATEGORY_VISIBILITY, GRID_HOMEPAGE_ENABLED } from '@/app/config'; import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog'; import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot'; import { PhotoSetCategories } from '@/category'; import { formatCameraText } from '@/camera'; import { formatFocalLength } from '@/focal'; import { formatRecipe } from '@/recipe'; import IconLens from '../components/icons/IconLens'; import { formatLensText } from '@/lens'; import IconTag from '../components/icons/IconTag'; import IconCamera from '../components/icons/IconCamera'; import IconPhoto from '../components/icons/IconPhoto'; import IconRecipe from '../components/icons/IconRecipe'; import IconFocalLength from '../components/icons/IconFocalLength'; import IconFilm from '../components/icons/IconFilm'; import IconLock from '../components/icons/IconLock'; import useVisualViewportHeight from '@/utility/useVisualViewport'; import useMaskedScroll from '../components/useMaskedScroll'; import { labelForFilm } from '@/film'; 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 explicitKey?: string keywords?: string[] accessory?: ReactNode annotation?: ReactNode annotationAria?: string path?: string action?: () => void | Promise } 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({ cameras, lenses, tags, recipes, films, focalLengths, showDebugTools, footer, }: { showDebugTools?: boolean footer?: string } & PhotoSetCategories) { const pathname = usePathname(); const { isUserSignedIn, clearAuthStateAndRedirectIfNecessary, isCommandKOpen: isOpen, startUpload, photosCountTotal, photosCountHidden, uploadsCount, tagsCount, recipesCount, selectedPhotoIds, setSelectedPhotoIds, insightsIndicatorStatus, isGridHighDensity, areZoomControlsShown, arePhotosMatted, shouldShowBaselineGrid, shouldDebugImageFallbacks, shouldDebugInsights, shouldDebugRecipeOverlays, setIsCommandKOpen: setIsOpen, setShouldShowBaselineGrid, setIsGridHighDensity, setAreZoomControlsShown, setArePhotosMatted, setShouldDebugImageFallbacks, setShouldDebugInsights, setShouldDebugRecipeOverlays, } = useAppState(); const isOpenRef = useRef(isOpen); const refInput = useRef(null); const mobileViewportHeight = useVisualViewportHeight(); const heightMaximum = '18rem'; const maxHeight = useMemo(() => { const positionY = refInput.current?.getBoundingClientRect().y; return mobileViewportHeight && positionY ? `min(${mobileViewportHeight - positionY - 32}px, ${heightMaximum})` : heightMaximum; }, [mobileViewportHeight]); const refScroll = useRef(null); const { maskStyle, updateMask } = useMaskedScroll({ ref: refScroll, updateMaskOnEvents: false, fadeSize: 50, }); // Manage action/path waiting state const [keyWaiting, setKeyWaiting] = useState(); const [isPending, startTransition] = useTransition(); const [isWaitingForAction, setIsWaitingForAction] = useState(false); const isWaiting = isPending || isWaitingForAction; const shouldCloseAfterWaiting = useRef(false); useEffect(() => { if (!isWaiting) { setKeyWaiting(undefined); if (shouldCloseAfterWaiting.current) { setIsOpen?.(false); shouldCloseAfterWaiting.current = false; } } }, [isWaiting, 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; if (isOpen) { const timeout = setTimeout(updateMask, 100); return () => clearTimeout(timeout); } }, [isOpen, updateMask]); 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) { setQueryLive(''); setQueriedSections([]); setIsLoading(false); } }, [isOpen]); const tagsIncludingHidden = useMemo(() => addHiddenToTags(tags, photosCountHidden) , [tags, photosCountHidden]); const categorySections: CommandKSection[] = useMemo(() => CATEGORY_VISIBILITY .map(category => { switch (category) { case 'cameras': return { heading: 'Cameras', accessory: , items: cameras.map(({ camera, count }) => ({ label: formatCameraText(camera), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForCamera(camera), })), }; case 'lenses': return { heading: 'Lenses', accessory: , items: lenses.map(({ lens, count }) => ({ label: formatLensText(lens, 'medium'), explicitKey: formatLensText(lens, 'long'), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForLens(lens), })), }; case 'tags': return { heading: 'Tags', accessory: , items: tagsIncludingHidden.map(({ tag, count }) => ({ label: formatTag(tag), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForTag(tag), })), }; case 'recipes': return { heading: 'Recipes', accessory: , items: recipes.map(({ recipe, count }) => ({ label: formatRecipe(recipe), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForRecipe(recipe), })), }; case 'films': return { heading: 'Films', accessory: , items: films.map(({ film, count }) => ({ label: labelForFilm(film).medium, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForFilm(film), })), }; case 'focal-lengths': return { heading: 'Focal Lengths', accessory: , items: focalLengths.map(({ focal, count }) => ({ label: formatFocalLength(focal)!, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForFocalLength(focal), })), }; } }) .filter(Boolean) as CommandKSection[] , [tagsIncludingHidden, cameras, lenses, recipes, films, focalLengths]); 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 pageFeed: CommandKItem = { label: GRID_HOMEPAGE_ENABLED ? 'Feed' : 'Feed (Home)', path: PATH_FEED_INFERRED, }; const pageGrid: CommandKItem = { label: GRID_HOMEPAGE_ENABLED ? 'Grid (Home)' : 'Grid', path: PATH_GRID_INFERRED, }; const pageItems: CommandKItem[] = GRID_HOMEPAGE_ENABLED ? [pageGrid, pageFeed] : [pageFeed, pageGrid]; const sectionPages: CommandKSection = { heading: 'Pages', accessory: , items: pageItems, }; const adminSection: CommandKSection = { heading: 'Admin', accessory: , items: [], }; if (isUserSignedIn) { adminSection.items.push({ label: 'Upload Photos', annotation: , action: startUpload, }); if (uploadsCount) { adminSection.items.push({ label: `Uploads (${uploadsCount})`, annotation: , path: PATH_ADMIN_UPLOADS, }); } adminSection.items.push({ label: `Manage Photos (${photosCountTotal})`, annotation: , path: PATH_ADMIN_PHOTOS, }); if (tagsCount) { adminSection.items.push({ label: `Manage Tags (${tagsCount})`, annotation: , path: PATH_ADMIN_TAGS, }); } if (recipesCount) { adminSection.items.push({ label: `Manage Recipes (${recipesCount})`, annotation: , path: PATH_ADMIN_RECIPES, }); } adminSection.items.push({ label: selectedPhotoIds === undefined ? 'Batch Edit Photos ...' : 'Exit Batch Edit', annotation: , path: selectedPhotoIds === undefined ? PATH_GRID_INFERRED : undefined, action: selectedPhotoIds === undefined ? () => setSelectedPhotoIds?.([]) : () => setSelectedPhotoIds?.(undefined), }, { label: App Insights {insightsIndicatorStatus && } , keywords: ['app insights'], annotation: , path: PATH_ADMIN_INSIGHTS, }, { label: 'App Config', annotation: , path: PATH_ADMIN_CONFIGURATION, }); if (showDebugTools) { adminSection.items.push({ label: 'Baseline Overview', annotation: , path: PATH_ADMIN_BASELINE, }, { label: 'Components Overview', annotation: , path: PATH_ADMIN_COMPONENTS, }); } adminSection.items.push({ label: 'Sign Out', action: () => signOutAction() .then(clearAuthStateAndRedirectIfNecessary) .then(() => setIsOpen?.(false)), }); } else { adminSection.items.push({ 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)} noPadding fast > {DIALOG_TITLE} {DIALOG_DESCRIPTION}
{ setQueryLive(e.currentTarget.value); updateMask(); }} className={clsx( 'w-full min-w-0!', 'focus:ring-0', isPlaceholderVisible || isLoading && 'pr-10!', '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 && }
*>*>*]:mt-2.5', )} style={{ ...maskStyle, maxHeight }} >
{isLoading ? 'Searching ...' : 'No results found'} {queriedSections .concat(categorySections) .concat(sectionPages) .concat(adminSection) .concat(clientSections) .filter(({ items }) => items.length > 0) .map(({ heading, accessory, items }) => {accessory &&
{accessory}
} {heading}
} className={clsx( 'uppercase', 'select-none', )} > {items.map(({ label, explicitKey, keywords, accessory, annotation, annotationAria, path, action, }) => { const key = `${heading} ${explicitKey ?? label}`; return { if (action) { const result = action(); if (result instanceof Promise) { setKeyWaiting(key); setIsWaitingForAction(true); result.then(shouldClose => { shouldCloseAfterWaiting.current = shouldClose === true; setIsWaitingForAction(false); }); } else { if (!path) { setIsOpen?.(false); } } } if (path) { if (path !== pathname) { setKeyWaiting(key); shouldCloseAfterWaiting.current = true; startTransition(() => { router.push(path, { scroll: true }); }); } else { setIsOpen?.(false); } } }} accessory={accessory} annotation={annotation} annotationAria={annotationAria} loading={key === keyWaiting} disabled={isPending && key !== keyWaiting} />; })} )} {footer && !queryLive &&
{footer}
}
); }