'use client'; import { Command } from 'cmdk'; import { ReactNode, SetStateAction, Dispatch, useEffect, useMemo, useRef, useState, useTransition, } from 'react'; import { PATH_ABOUT, 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_FULL_INFERRED, PATH_GRID_INFERRED, PATH_SIGN_IN, pathForAlbum, pathForCamera, pathForFilm, pathForFocalLength, pathForLens, pathForPhoto, pathForRecipe, pathForTag, pathForYear, PREFIX_RECENTS, } from '../app/path'; 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 { IoClose, IoInvertModeSharp } from 'react-icons/io5'; import { useAppState } from '@/app/AppState'; import { searchPhotosAction } from '@/photo/actions'; import { RiToolsFill } from 'react-icons/ri'; import { signOutAction } from '@/auth/actions'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoSmall from '@/photo/PhotoSmall'; import { addPrivateToTags, formatTag, isTagFavs, isTagPrivate, limitTagsByCount, } from '@/tag'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import CommandKItem from './CommandKItem'; import { CATEGORY_VISIBILITY, COLOR_SORT_ENABLED, GRID_HOMEPAGE_ENABLED, HIDE_TAGS_WITH_ONE_PHOTO, SHOW_ABOUT_PAGE, } 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 IconYear from '../components/icons/IconYear'; import useViewportHeight from '@/utility/useViewportHeight'; import useMaskedScroll from '../components/useMaskedScroll'; import { labelForFilm } from '@/film'; import IconFavs from '@/components/icons/IconFavs'; import { useAppText } from '@/i18n/state/client'; import LoaderButton from '@/components/primitives/LoaderButton'; import IconRecents from '@/components/icons/IconRecents'; import { CgClose, CgFileDocument } from 'react-icons/cg'; import { FaRegUserCircle } from 'react-icons/fa'; import { formatDistanceToNow } from 'date-fns'; import IconCheck from '@/components/icons/IconCheck'; import { getSortStateFromPath } from '@/photo/sort/path'; import IconSort from '@/components/icons/IconSort'; import { useSelectPhotosState } from '@/admin/select/SelectPhotosState'; import IconAlbum from '@/components/icons/IconAlbum'; 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; const MAX_HEIGHT = '20rem'; 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 renderCheck = (isChecked?: boolean) => isChecked ? : undefined; const renderToggle = ( label: string, onToggle?: Dispatch>, isEnabled?: boolean, ): CommandKItem => ({ label: `Toggle ${label}`, action: () => onToggle?.(prev => !prev), annotation: renderCheck(isEnabled), }); export default function CommandKClient({ recents, years: _years, cameras, lenses, albums, tags: _tags, recipes, films, focalLengths, footer, }: { footer?: string } & PhotoSetCategories) { const pathname = usePathname(); const appText = useAppText(); const { isUserSignedIn, clearAuthStateAndRedirectIfNecessary, isCommandKOpen: isOpen, startUpload, photosCountTotal, photosCountHidden = 0, uploadsCount, tagsCount, recipesCount, insightsIndicatorStatus, isGridHighDensity, areZoomControlsShown, arePhotosMatted, areAdminDebugToolsEnabled, shouldShowBaselineGrid, shouldDebugImageFallbacks, shouldDebugInsights, shouldDebugRecipeOverlays, setIsCommandKOpen: setIsOpen, setShouldShowBaselineGrid, setIsGridHighDensity, setAreZoomControlsShown, setArePhotosMatted, setShouldDebugImageFallbacks, setShouldDebugInsights, setShouldDebugRecipeOverlays, } = useAppState(); const { isSelectingPhotos, startSelectingPhotos, stopSelectingPhotos, } = useSelectPhotosState(); const { doesPathOfferSort, isSortedByDefault, isAscending, isTakenAt, isUploadedAt, isColor, descendingLabel, ascendingLabel, pathDescending, pathAscending, pathTakenAt, pathUploadedAt, pathColor, pathClearSort, } = useMemo( () => getSortStateFromPath(pathname, appText), [pathname, appText], ); const isOpenRef = useRef(isOpen); const refInput = useRef(null); const mobileViewportHeight = useViewportHeight(); const maxHeight = useMemo(() => { const positionY = refInput.current?.getBoundingClientRect().y; return mobileViewportHeight && positionY ? `min(${mobileViewportHeight - positionY - 32}px, ${MAX_HEIGHT})` : MAX_HEIGHT; }, [mobileViewportHeight]); const refScroll = useRef(null); const { styleMask, updateMask } = useMaskedScroll({ ref: refScroll, updateMaskOnEvents: false, hideScrollbar: false, }); // 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, setQueryLiveRaw] = useState(''); const [queryDebouncedRaw] = useDebounce(queryLiveRaw, 500, { trailing: true }); // 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, appText]); useEffect(() => { if (queryLive === '') { setQueriedSections([]); setIsLoading(false); } else if (queryLive.length >= MINIMUM_QUERY_LENGTH) { setIsLoading(true); } }, [queryLive]); useEffect(() => { if (!isOpen) { setQueryLiveRaw(''); setQueriedSections([]); setIsLoading(false); } }, [isOpen]); const recent = recents[0]; const recentsStatus = useMemo(() => { if (!recent) { return undefined; } const { count, lastModified } = recent; const subhead = appText.category.recentSubhead( formatDistanceToNow(lastModified), ); return count ? { count, subhead } : undefined; }, [recent, appText]); // Years only accessible by search const years = useMemo(() => _years.filter(({ year }) => queryLive && year.includes(queryLive)) , [_years, queryLive]); const tags = useMemo(() => { const tagsIncludingPrivate = photosCountHidden > 0 ? addPrivateToTags(_tags, photosCountHidden) : _tags; return HIDE_TAGS_WITH_ONE_PHOTO ? limitTagsByCount(tagsIncludingPrivate, 2, queryLive) : tagsIncludingPrivate; }, [_tags, photosCountHidden, queryLive]); const categorySections: CommandKSection[] = useMemo(() => CATEGORY_VISIBILITY .map(category => { switch (category) { case 'recents': return { heading: appText.category.recentPlural, accessory: , items: recentsStatus ? [{ label: recentsStatus.subhead, annotation: formatCount(recentsStatus.count), annotationAria: formatCountDescriptive(recentsStatus.count), path: PREFIX_RECENTS, }] : [], }; case 'years': return { heading: appText.category.yearPlural, accessory: , items: years.map(({ year, count }) => ({ label: year, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForYear(year), })), }; case 'cameras': return { heading: appText.category.cameraPlural, accessory: , items: cameras.map(({ camera, count }) => ({ label: formatCameraText(camera), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForCamera(camera), })), }; case 'lenses': return { heading: appText.category.lensPlural, accessory: , items: lenses.map(({ lens, count }) => ({ label: formatLensText(lens, 'medium'), explicitKey: formatLensText(lens, 'long'), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForLens(lens), })), }; case 'albums': return { heading: appText.category.albumPlural, accessory: , items: albums.map(({ album, count }) => ({ label: album.title, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForAlbum(album), })), }; case 'tags': return { heading: appText.category.tagPlural, accessory: , items: tags.map(({ tag, count }) => ({ explicitKey: formatTag(tag), label: {formatTag(tag)} {isTagFavs(tag) && } {isTagPrivate(tag) && } , annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForTag(tag), })), }; case 'recipes': return { heading: appText.category.recipePlural, accessory: , items: recipes.map(({ recipe, count }) => ({ label: formatRecipe(recipe), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForRecipe(recipe), })), }; case 'films': return { heading: appText.category.filmPlural, accessory: , items: films.map(({ film, count }) => ({ label: labelForFilm(film).medium, annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForFilm(film), })), }; case 'focal-lengths': return { heading: appText.category.focalLengthPlural, accessory: , items: focalLengths.map(({ focal, count }) => ({ label: formatFocalLength(focal), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForFocalLength(focal), })), }; } }) .filter(Boolean) as CommandKSection[] , [ appText, recentsStatus, years, cameras, lenses, albums, tags, recipes, films, focalLengths, ]); const clientSections: CommandKSection[] = [{ heading: appText.theme.theme, accessory: , items: [{ label: appText.theme.system, annotation: , action: () => setTheme('system'), }, { label: appText.theme.light, annotation: , action: () => setTheme('light'), }, { label: appText.theme.dark, annotation: , action: () => setTheme('dark'), }], }]; if (isUserSignedIn && areAdminDebugToolsEnabled) { 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 sortItems = [{ label: descendingLabel, path: pathDescending, annotation: renderCheck(!isAscending), }, { label: ascendingLabel, path: pathAscending, annotation: renderCheck(isAscending), }, { label: appText.sort.byTakenAt, path: pathTakenAt, annotation: renderCheck(isTakenAt), }, { label: appText.sort.byUploadedAt, path: pathUploadedAt, annotation: renderCheck(isUploadedAt), }]; if (COLOR_SORT_ENABLED) { sortItems.push({ label: appText.sort.byColor, path: pathColor, annotation: renderCheck(isColor), }); } if (!isSortedByDefault) { sortItems.push({ label: appText.sort.clearSort, path: pathClearSort, annotation: , }); } const sortSection: CommandKSection = { heading: appText.sort.sort, accessory: , items: doesPathOfferSort ? sortItems : [], }; const pageFull: CommandKItem = { label: GRID_HOMEPAGE_ENABLED ? appText.nav.full : `${appText.nav.full} (${appText.nav.home})`, path: PATH_FULL_INFERRED, }; const pageGrid: CommandKItem = { label: GRID_HOMEPAGE_ENABLED ? `${appText.nav.grid} (${appText.nav.home})` : appText.nav.grid, path: PATH_GRID_INFERRED, }; const pageItems: CommandKItem[] = GRID_HOMEPAGE_ENABLED ? [pageGrid, pageFull] : [pageFull, pageGrid]; if (SHOW_ABOUT_PAGE) { pageItems.push({ label: appText.nav.about, path: PATH_ABOUT, }); } const sectionPages: CommandKSection = { heading: appText.cmdk.pages, accessory: , items: pageItems, }; const adminSection: CommandKSection = { heading: appText.nav.admin, accessory: , items: [], }; if (isUserSignedIn) { adminSection.items.push({ label: appText.admin.uploadPhotos, annotation: , action: startUpload, }); if (uploadsCount) { adminSection.items.push({ label: `${appText.admin.uploadPlural} (${uploadsCount})`, annotation: , path: PATH_ADMIN_UPLOADS, }); } adminSection.items.push({ label: `${appText.admin.managePhotos} (${photosCountTotal})`, annotation: , path: PATH_ADMIN_PHOTOS, }); if (tagsCount) { adminSection.items.push({ label: `${appText.admin.manageTags} (${tagsCount})`, annotation: , path: PATH_ADMIN_TAGS, }); } if (recipesCount) { adminSection.items.push({ label: `${appText.admin.manageRecipes} (${recipesCount})`, annotation: , path: PATH_ADMIN_RECIPES, }); } adminSection.items.push({ label: isSelectingPhotos ? appText.admin.selectPhotosExit : appText.admin.selectPhotos, annotation: , // Search by legacy label keywords: ['batch', 'edit'], action: () => { if (!isSelectingPhotos) { startSelectingPhotos?.(); } else { stopSelectingPhotos?.(); } }, }, { label: {appText.admin.appInsights} {insightsIndicatorStatus && } , keywords: ['app insights'], annotation: , path: PATH_ADMIN_INSIGHTS, }, { label: appText.admin.appConfig, annotation: , path: PATH_ADMIN_CONFIGURATION, }); if (areAdminDebugToolsEnabled) { adminSection.items.push({ label: 'Baseline Overview', annotation: , path: PATH_ADMIN_BASELINE, }, { label: 'Components Overview', annotation: , path: PATH_ADMIN_COMPONENTS, }); } adminSection.items.push({ label: appText.auth.signOut, action: () => signOutAction() .then(clearAuthStateAndRedirectIfNecessary) .then(() => setIsOpen?.(false)), }); } else { adminSection.items.push({ label: appText.auth.signIn, 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}
{ setQueryLiveRaw(value); updateMask(); }} className={clsx( 'grow p-0', 'focus:ring-0', 'border-transparent focus:border-transparent', 'bg-transparent rounded-none', 'placeholder:text-gray-400/80', 'dark:placeholder:text-gray-700', 'focus:outline-hidden', isPending && 'opacity-20', )} placeholder={appText.cmdk.placeholder} disabled={isPending} /> {isLoading && !isPending ? : { if (queryLiveRaw) { setQueryLiveRaw(''); updateMask(); } else { setIsOpen?.(false); } }} > {queryLiveRaw ? : <> ESC } }
{isLoading ? appText.cmdk.searching : appText.cmdk.noResults} {queriedSections .concat(categorySections) .concat(sortSection) .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)); } else { setIsOpen?.(false); } } }} accessory={accessory} annotation={annotation} annotationAria={annotationAria} loading={key === keyWaiting} disabled={isPending && key !== keyWaiting} />; })} )} {footer && !queryLive &&
{footer}
}
); }