538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
'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<void>
|
|
}
|
|
|
|
export type CommandKSection = {
|
|
heading: string
|
|
accessory?: ReactNode
|
|
items: CommandKItem[]
|
|
}
|
|
|
|
const renderToggle = (
|
|
label: string,
|
|
onToggle?: Dispatch<SetStateAction<boolean>>,
|
|
isEnabled?: boolean,
|
|
): CommandKItem => ({
|
|
label: `Toggle ${label}`,
|
|
action: () => onToggle?.(prev => !prev),
|
|
annotation: isEnabled ? <FaCheck size={12} /> : 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<string>();
|
|
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<CommandKSection[]>([]);
|
|
|
|
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: <TbPhoto size={14} />,
|
|
items: photos.map(photo => ({
|
|
label: titleForPhoto(photo),
|
|
keywords: getKeywordsForPhoto(photo),
|
|
annotation: <PhotoDate {...{ photo, timezone: undefined }} />,
|
|
accessory: <PhotoSmall photo={photo} />,
|
|
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: <FaTag
|
|
size={10}
|
|
className="translate-x-[1px] translate-y-[0.75px]"
|
|
/>,
|
|
items: tagsIncludingHidden.map(({ tag, count }) => ({
|
|
label: formatTag(tag),
|
|
annotation: formatCount(count),
|
|
annotationAria: formatCountDescriptive(count),
|
|
path: pathForTag(tag),
|
|
})),
|
|
};
|
|
|
|
const clientSections: CommandKSection[] = [{
|
|
heading: 'Theme',
|
|
accessory: <IoInvertModeSharp
|
|
size={14}
|
|
className="translate-y-[0.5px] translate-x-[-1px]"
|
|
/>,
|
|
items: [{
|
|
label: 'Use System',
|
|
annotation: <BiDesktop />,
|
|
action: () => setTheme('system'),
|
|
}, {
|
|
label: 'Light Mode',
|
|
annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
|
|
action: () => setTheme('light'),
|
|
}, {
|
|
label: 'Dark Mode',
|
|
annotation: <BiMoon className="translate-x-[1px]" />,
|
|
action: () => setTheme('dark'),
|
|
}],
|
|
}];
|
|
|
|
if (isUserSignedIn && showDebugTools) {
|
|
clientSections.push({
|
|
heading: 'Debug Tools',
|
|
accessory: <RiToolsFill size={16} className="translate-x-[-1px]" />,
|
|
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: <HiDocumentText size={15} className="translate-x-[-1px]" />,
|
|
items: pagesItems,
|
|
};
|
|
|
|
const adminSection: CommandKSection = {
|
|
heading: 'Admin',
|
|
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
|
|
items: isUserSignedIn
|
|
? ([{
|
|
label: 'Manage Photos',
|
|
annotation: <BiLockAlt />,
|
|
path: PATH_ADMIN_PHOTOS,
|
|
}, {
|
|
label: 'Manage Uploads',
|
|
annotation: <BiLockAlt />,
|
|
path: PATH_ADMIN_UPLOADS,
|
|
}, {
|
|
label: 'Manage Tags',
|
|
annotation: <BiLockAlt />,
|
|
path: PATH_ADMIN_TAGS,
|
|
}, {
|
|
label: 'App Config',
|
|
annotation: <BiLockAlt />,
|
|
path: PATH_ADMIN_CONFIGURATION,
|
|
}, {
|
|
label: <span className="flex items-center gap-3">
|
|
App Insights
|
|
{insightIndicatorStatus && <FaCircle
|
|
size={8}
|
|
className={clsx(
|
|
insightIndicatorStatus === 'blue'
|
|
? 'text-blue-500'
|
|
: 'text-amber-500',
|
|
)}
|
|
/>}
|
|
</span>,
|
|
keywords: ['app insights'],
|
|
annotation: <BiLockAlt />,
|
|
path: PATH_ADMIN_INSIGHTS,
|
|
}, {
|
|
label: selectedPhotoIds === undefined
|
|
? 'Select Multiple Photos'
|
|
: 'Exit Select Multiple Photos',
|
|
annotation: <BiLockAlt />,
|
|
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 (
|
|
<Command.Dialog
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
filter={(value, search, keywords) => {
|
|
const searchFormatted = search.trim().toLocaleLowerCase();
|
|
return (
|
|
value.toLocaleLowerCase().includes(searchFormatted) ||
|
|
keywords?.some(keyword => keyword.includes(searchFormatted))
|
|
) ? 1 : 0 ;
|
|
}}
|
|
loop
|
|
>
|
|
<Modal
|
|
anchor='top'
|
|
onClose={() => setIsOpen?.(false)}
|
|
fast
|
|
>
|
|
<div className="space-y-1.5">
|
|
<div className="relative">
|
|
<VisuallyHidden.Root>
|
|
<DialogTitle>{DIALOG_TITLE}</DialogTitle>
|
|
<DialogDescription>{DIALOG_DESCRIPTION}</DialogDescription>
|
|
</VisuallyHidden.Root>
|
|
<Command.Input
|
|
onChangeCapture={(e) => 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 &&
|
|
<span className={clsx(
|
|
'absolute top-2.5 right-0 w-8',
|
|
'flex items-center justify-center translate-y-[2px]',
|
|
)}>
|
|
<Spinner size={16} />
|
|
</span>}
|
|
</div>
|
|
<Command.List className={clsx(
|
|
'relative overflow-y-auto',
|
|
'max-h-48 sm:max-h-72',
|
|
)}>
|
|
<Command.Empty className="mt-1 pl-3 text-dim">
|
|
{isLoading ? 'Searching ...' : 'No results found'}
|
|
</Command.Empty>
|
|
{queriedSections
|
|
.concat(SECTION_TAGS)
|
|
.concat(serverSections)
|
|
.concat(sectionPages)
|
|
.concat(adminSection)
|
|
.concat(clientSections)
|
|
.filter(({ items }) => items.length > 0)
|
|
.map(({ heading, accessory, items }) =>
|
|
<Command.Group
|
|
key={heading}
|
|
heading={<div className={clsx(
|
|
'flex items-center',
|
|
'px-2',
|
|
isPending && 'opacity-20',
|
|
)}>
|
|
{accessory &&
|
|
<div className="w-5">{accessory}</div>}
|
|
{heading}
|
|
</div>}
|
|
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 <CommandKItem
|
|
key={key}
|
|
label={label}
|
|
value={key}
|
|
keywords={keywords}
|
|
onSelect={() => {
|
|
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}
|
|
/>;
|
|
})}
|
|
</Command.Group>)}
|
|
{footer && !queryLive &&
|
|
<div className="text-center text-dim pt-3 sm:pt-4">
|
|
{footer}
|
|
</div>}
|
|
</Command.List>
|
|
</div>
|
|
</Modal>
|
|
</Command.Dialog>
|
|
);
|
|
}
|