Add key commands to admin photo menu

This commit is contained in:
Sam Becker 2025-04-26 16:25:51 -05:00
parent 9bf0665243
commit 5180ea6276
5 changed files with 83 additions and 53 deletions

View File

@ -30,11 +30,13 @@ export default function AdminPhotoMenu({
photo,
revalidatePhoto,
includeFavorite = true,
showKeyCommands,
...props
}: Omit<ComponentProps<typeof MoreMenu>, 'sections'> & {
photo: Photo
revalidatePhoto?: RevalidatePhoto
includeFavorite?: boolean
showKeyCommands?: boolean
}) {
const { isUserSignedIn, registerAdminUpdate } = useAppState();
@ -51,6 +53,7 @@ export default function AdminPhotoMenu({
className="translate-x-[0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
...showKeyCommands && { keyCommand: 'E' },
}];
if (includeFavorite) {
sectionMain.push({
@ -64,6 +67,7 @@ export default function AdminPhotoMenu({
photo.id,
shouldRedirectFav,
).then(() => revalidatePhoto?.(photo.id)),
...showKeyCommands && { keyCommand: isFav ? 'X' : 'P' },
});
}
sectionMain.push({
@ -74,6 +78,7 @@ export default function AdminPhotoMenu({
/>,
href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo),
...showKeyCommands && { keyCommand: 'D' },
});
sectionMain.push({
label: 'Sync',
@ -116,6 +121,7 @@ export default function AdminPhotoMenu({
return [sectionMain, sectionDelete];
}, [
photo,
showKeyCommands,
includeFavorite,
isFav,
shouldRedirectFav,

View File

@ -2,10 +2,17 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { ReactNode, useEffect, useState, useTransition } from 'react';
import {
ComponentProps,
ReactNode,
useEffect,
useState,
useTransition,
} from 'react';
import LoaderButton from '../primitives/LoaderButton';
import { usePathname, useRouter } from 'next/navigation';
import { downloadFileFromBrowser } from '@/utility/url';
import KeyCommand from '../primitives/KeyCommand';
export default function MoreMenuItem({
label,
@ -19,6 +26,8 @@ export default function MoreMenuItem({
action,
dismissMenu,
shouldPreventDefault = true,
keyCommand,
keyCommandModifier,
}: {
label: string
labelComplex?: ReactNode
@ -31,6 +40,8 @@ export default function MoreMenuItem({
action?: () => Promise<void | boolean> | void
dismissMenu?: () => void
shouldPreventDefault?: boolean
keyCommand?: string
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
}) {
const router = useRouter();
@ -66,8 +77,8 @@ export default function MoreMenuItem({
<DropdownMenu.Item
disabled={isLoading}
className={clsx(
'flex items-center h-8.5',
'pl-2 pr-3 py-2 rounded-sm',
'flex items-center h-8.5 gap-4',
'px-2 py-2 rounded-sm',
'select-none hover:outline-hidden',
getColorClasses(),
'whitespace-nowrap',
@ -122,7 +133,7 @@ export default function MoreMenuItem({
isLoading={isLoading || isPending}
hideTextOnMobile={false}
styleAs="link-without-hover"
className="translate-y-[0.5px] text-sm"
className="translate-y-[0.5px] text-sm grow"
classNameIcon="translate-y-[-0.5px]!"
>
<span>
@ -133,6 +144,10 @@ export default function MoreMenuItem({
{annotation}
</span>}
</LoaderButton>
{keyCommand &&
<KeyCommand modifier={keyCommandModifier}>
{keyCommand}
</KeyCommand>}
</DropdownMenu.Item>
);
}

View File

@ -9,6 +9,7 @@ import useClickInsideOutside from '@/utility/useClickInsideOutside';
import KeyCommand from './KeyCommand';
export default function TooltipPrimitive({
content: contentProp,
children,
className,
classNameTrigger: classNameTriggerProp,
sideOffset = 10,
@ -18,9 +19,9 @@ export default function TooltipPrimitive({
color,
keyCommand,
keyCommandModifier,
children,
}: {
content?: ReactNode
children: ReactNode
className?: string
classNameTrigger?: string
sideOffset?: number
@ -30,7 +31,6 @@ export default function TooltipPrimitive({
color?: ComponentProps<typeof MenuSurface>['color']
keyCommand?: string
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
children: ReactNode
}) {
const refTrigger = useRef<HTMLButtonElement>(null);
const refContent = useRef<HTMLDivElement>(null);

View File

@ -252,6 +252,7 @@ export default function PhotoLarge({
revalidatePhoto,
includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
showKeyCommands: true,
}} />;
const largePhotoContainerClassName = clsx(

View File

@ -1,8 +1,9 @@
'use client';
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useRef } from 'react';
import {
Photo,
downloadFileNameForPhoto,
getNextPhoto,
getPreviousPhoto,
} from '@/photo';
@ -18,8 +19,9 @@ import useNavigateOrRunActionWithToast
import { toggleFavoritePhotoAction } from './actions';
import { isPhotoFav } from '@/tag';
import Tooltip from '@/components/Tooltip';
const LISTENER_KEYUP = 'keyup';
import { ALLOW_PUBLIC_DOWNLOADS } from '@/app/config';
import { downloadFileFromBrowser } from '@/utility/url';
import useKeydownHandler from '@/utility/useKeydownHandler';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
@ -34,17 +36,17 @@ export default function PhotoPrevNext({
photos?: Photo[]
className?: string
} & PhotoSetCategory) {
const {
setNextPhotoAnimation,
shouldRespondToKeyboardCommands,
isUserSignedIn,
} = useAppState();
const { setNextPhotoAnimation, isUserSignedIn } = useAppState();
const photoTitle = photo
? photo.title
? `'${photo.title}'`
: 'photo'
: undefined;
const downloadUrl = photo?.url;
const downloadFileName = photo
? downloadFileNameForPhoto(photo)
: undefined;
const toggleFavorite = useCallback(() => {
if (photo?.id) {
@ -81,55 +83,61 @@ export default function PhotoPrevNext({
? pathForPhoto({ photo: nextPhoto, ...categories })
: undefined;
useEffect(() => {
if (shouldRespondToKeyboardCommands) {
const onKeyUp = (e: KeyboardEvent) => {
switch (e.key.toUpperCase()) {
case 'ARROWLEFT':
case 'J':
if (pathPrevious) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
refPrevious.current?.click();
}
break;
case 'ARROWRIGHT':
case 'L':
if (pathNext) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
refNext.current?.click();
}
break;
case 'E':
if (isUserSignedIn) { navigateToPhotoEdit(); }
break;
case 'P':
if (isUserSignedIn && photo && !isPhotoFav(photo)) {
favoritePhoto();
}
break;
case 'X':
if (isUserSignedIn && photo && isPhotoFav(photo)) {
unfavoritePhoto();
}
break;
};
};
window.addEventListener(LISTENER_KEYUP, onKeyUp);
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
}
const onKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key.toUpperCase()) {
case 'ARROWLEFT':
case 'J':
if (pathPrevious) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
refPrevious.current?.click();
}
break;
case 'ARROWRIGHT':
case 'L':
if (pathNext) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
refNext.current?.click();
}
break;
case 'E':
if (isUserSignedIn) {
navigateToPhotoEdit();
}
break;
case 'P':
if (isUserSignedIn && photo && !isPhotoFav(photo)) {
favoritePhoto();
}
break;
case 'X':
if (isUserSignedIn && photo && isPhotoFav(photo)) {
unfavoritePhoto();
}
break;
case 'D':
if (
(isUserSignedIn || ALLOW_PUBLIC_DOWNLOADS) &&
downloadUrl &&
downloadFileName
) {
downloadFileFromBrowser(downloadUrl, downloadFileName);
}
break;
};
}, [
shouldRespondToKeyboardCommands,
setNextPhotoAnimation,
pathPrevious,
pathNext,
isUserSignedIn,
photoTitle,
navigateToPhotoEdit,
photo,
favoritePhoto,
unfavoritePhoto,
downloadUrl,
downloadFileName,
]);
useKeydownHandler({ onKeyDown });
return (
<div className={clsx(
'flex items-center',