Add header icons/annotations to cmd-k menu, optimize behavior
This commit is contained in:
parent
6221773cf9
commit
6aa351cf29
@ -8,6 +8,7 @@ import { useDebounce } from 'use-debounce';
|
||||
import Spinner from './Spinner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
|
||||
@ -16,7 +17,8 @@ export type CommandKSection = {
|
||||
accessory?: ReactNode
|
||||
items: {
|
||||
label: string
|
||||
note?: string
|
||||
annotation?: ReactNode
|
||||
annotationAria?: string
|
||||
accessory?: ReactNode
|
||||
path?: string
|
||||
action?: () => void
|
||||
@ -32,7 +34,7 @@ export default function CommandKClient({
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [queryRaw, setQueryRaw] = useState('');
|
||||
const [queryDebounced] = useDebounce(queryRaw, 500);
|
||||
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
|
||||
@ -70,16 +72,27 @@ export default function CommandKClient({
|
||||
}
|
||||
}, [queryRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQueryRaw('');
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const sectionTheme: CommandKSection = {
|
||||
heading: 'Theme',
|
||||
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'),
|
||||
}],
|
||||
};
|
||||
@ -87,13 +100,7 @@ export default function CommandKClient({
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={isOpen => {
|
||||
if (!isOpen) {
|
||||
setQueryRaw('');
|
||||
setQueriedSections([]);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}}
|
||||
onOpenChange={setIsOpen}
|
||||
label="Global Command Menu"
|
||||
filter={(value, search) =>
|
||||
value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
|
||||
@ -132,10 +139,17 @@ export default function CommandKClient({
|
||||
.concat(sections)
|
||||
.concat(sectionTheme)
|
||||
.filter(({ items }) => items.length > 0)
|
||||
.map(({ heading, items }) =>
|
||||
.map(({ heading, accessory, items }) =>
|
||||
<Command.Group
|
||||
key={heading}
|
||||
heading={heading}
|
||||
heading={<div className={clsx(
|
||||
'flex items-center',
|
||||
'px-2',
|
||||
)}>
|
||||
{accessory &&
|
||||
<div className="w-5">{accessory}</div>}
|
||||
{heading}
|
||||
</div>}
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
'select-none',
|
||||
@ -146,7 +160,14 @@ export default function CommandKClient({
|
||||
'[&>*:first-child]:tracking-wider',
|
||||
)}
|
||||
>
|
||||
{items.map(({ accessory, label, note, path, action }) =>
|
||||
{items.map(({
|
||||
accessory,
|
||||
label,
|
||||
annotation,
|
||||
annotationAria,
|
||||
path,
|
||||
action,
|
||||
}) =>
|
||||
<Command.Item
|
||||
key={`${heading}-${label}`}
|
||||
value={`${heading}-${label}`}
|
||||
@ -170,9 +191,14 @@ export default function CommandKClient({
|
||||
<span className="grow text-ellipsis truncate">
|
||||
{label}
|
||||
</span>
|
||||
{note &&
|
||||
<span className="text-dim whitespace-nowrap">
|
||||
{note}
|
||||
{annotation &&
|
||||
<span
|
||||
className="text-dim whitespace-nowrap"
|
||||
aria-label={annotationAria}
|
||||
>
|
||||
<span aria-hidden={Boolean(annotationAria)}>
|
||||
{annotation}
|
||||
</span>
|
||||
</span>}
|
||||
</div>
|
||||
</Command.Item>)}
|
||||
|
||||
@ -24,6 +24,7 @@ import { getDimensionsFromSize } from '@/utility/size';
|
||||
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
||||
import { BLUR_ENABLED } from '@/site/config';
|
||||
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -150,9 +151,8 @@ export default function PhotoForm({
|
||||
sortTagsObjectWithoutFavs(uniqueTags ?? [])
|
||||
.map(({ tag, count }) => ({
|
||||
value: tag,
|
||||
annotation: `× ${count}`,
|
||||
annotationAria:
|
||||
`tagged in ${count} photo${count === 1 ? '' : 's'}`,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count, 'tagged'),
|
||||
}))
|
||||
)
|
||||
.map(([key, {
|
||||
|
||||
@ -20,6 +20,13 @@ import { getPhotos } from '@/services/vercel-postgres';
|
||||
import { titleForPhoto } from '@/photo';
|
||||
import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
import { sortTagsObject } from '@/tag';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { TbPhoto } from 'react-icons/tb';
|
||||
|
||||
export default async function CommandK() {
|
||||
const [
|
||||
@ -38,47 +45,65 @@ export default async function CommandK() {
|
||||
|
||||
const SECTION_TAGS: CommandKSection = {
|
||||
heading: 'Tags',
|
||||
items: tags.map(({ tag }) => ({
|
||||
accessory: <FaTag
|
||||
size={10}
|
||||
className="translate-x-[1px] translate-y-[0.5px]"
|
||||
/>,
|
||||
items: sortTagsObject(tags).map(({ tag, count }) => ({
|
||||
label: tag,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForTag(tag),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_CAMERAS: CommandKSection = {
|
||||
heading: 'Cameras',
|
||||
items: cameras.map(({ camera }) => ({
|
||||
accessory: <IoMdCamera />,
|
||||
items: cameras.map(({ camera, count }) => ({
|
||||
label: formatCameraText(camera),
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForCamera(camera),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_FILM: CommandKSection = {
|
||||
heading: 'Film Simulations',
|
||||
items: filmSimulations.map(({ simulation }) => ({
|
||||
accessory: <span className="w-3">
|
||||
<PhotoFilmSimulationIcon />
|
||||
</span>,
|
||||
items: filmSimulations.map(({ simulation, count }) => ({
|
||||
label: simulation,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForFilmSimulation(simulation),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_PAGES: CommandKSection = {
|
||||
heading: 'Pages',
|
||||
items: [{
|
||||
items: ([{
|
||||
label: 'Home',
|
||||
path: '/',
|
||||
}, {
|
||||
label: 'Grid',
|
||||
path:'/grid',
|
||||
}].concat(showAdminPages ? [{
|
||||
label: 'Admin » Photos',
|
||||
}] as CommandKSection['items']).concat(showAdminPages ? [{
|
||||
label: 'Admin / Photos',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_PHOTOS,
|
||||
}, {
|
||||
label: 'Admin » Uploads',
|
||||
label: 'Admin / Uploads',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_UPLOADS,
|
||||
}, {
|
||||
label: 'Admin » Tags',
|
||||
label: 'Admin / Tags',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_TAGS,
|
||||
}, {
|
||||
label: 'Admin » Config',
|
||||
label: 'Admin / Config',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_CONFIGURATION,
|
||||
}] : []),
|
||||
};
|
||||
@ -97,10 +122,11 @@ export default async function CommandK() {
|
||||
return photos.length > 0
|
||||
? [{
|
||||
heading: 'Photos',
|
||||
accessory: <TbPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
accessory: <PhotoTiny photo={photo} />,
|
||||
label: titleForPhoto(photo),
|
||||
note: formatDate(photo.takenAt),
|
||||
annotation: formatDate(photo.takenAt),
|
||||
path: pathForPhoto(photo),
|
||||
})),
|
||||
}]
|
||||
|
||||
@ -24,3 +24,14 @@ export const parameterize = (string: string) =>
|
||||
// Removes all non-alphanumeric characters
|
||||
.replaceAll(/([^a-z0-9-])/gi, '')
|
||||
.toLowerCase();
|
||||
|
||||
export const formatCount = (count: number) => `× ${count}`;
|
||||
|
||||
export const formatCountDescriptive = (
|
||||
count: number,
|
||||
verb = 'found',
|
||||
noun = 'photo',
|
||||
singular = '',
|
||||
plural = 's',
|
||||
) =>
|
||||
`${verb} in ${count} ${noun}${count === 1 ? singular : plural}`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user