Finalize key commands

This commit is contained in:
Sam Becker 2025-04-26 17:32:46 -05:00
parent 5180ea6276
commit 8d91804eb9
8 changed files with 105 additions and 36 deletions

View File

@ -25,6 +25,7 @@ import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit'; import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync'; import { photoNeedsToBeSynced } from '@/photo/sync';
import { KEY_COMMANDS } from '@/photo/key-commands';
export default function AdminPhotoMenu({ export default function AdminPhotoMenu({
photo, photo,
@ -53,7 +54,7 @@ export default function AdminPhotoMenu({
className="translate-x-[0.5px]" className="translate-x-[0.5px]"
/>, />,
href: pathForAdminPhotoEdit(photo.id), href: pathForAdminPhotoEdit(photo.id),
...showKeyCommands && { keyCommand: 'E' }, ...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
}]; }];
if (includeFavorite) { if (includeFavorite) {
sectionMain.push({ sectionMain.push({
@ -67,7 +68,11 @@ export default function AdminPhotoMenu({
photo.id, photo.id,
shouldRedirectFav, shouldRedirectFav,
).then(() => revalidatePhoto?.(photo.id)), ).then(() => revalidatePhoto?.(photo.id)),
...showKeyCommands && { keyCommand: isFav ? 'X' : 'P' }, ...showKeyCommands && {
keyCommand: isFav
? KEY_COMMANDS.unfavorite
: KEY_COMMANDS.favorite,
},
}); });
} }
sectionMain.push({ sectionMain.push({
@ -78,7 +83,7 @@ export default function AdminPhotoMenu({
/>, />,
href: photo.url, href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo), hrefDownloadName: downloadFileNameForPhoto(photo),
...showKeyCommands && { keyCommand: 'D' }, ...showKeyCommands && { keyCommand: KEY_COMMANDS.download },
}); });
sectionMain.push({ sectionMain.push({
label: 'Sync', label: 'Sync',
@ -96,6 +101,7 @@ export default function AdminPhotoMenu({
/>, />,
action: () => syncPhotoAction(photo.id) action: () => syncPhotoAction(photo.id)
.then(() => revalidatePhoto?.(photo.id)), .then(() => revalidatePhoto?.(photo.id)),
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync },
}); });
const sectionDelete: ComponentProps<typeof MoreMenuItem>[] = [{ const sectionDelete: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Delete', label: 'Delete',
@ -117,6 +123,10 @@ export default function AdminPhotoMenu({
}); });
} }
}, },
...showKeyCommands && {
keyCommandModifier: KEY_COMMANDS.delete[0],
keyCommand: KEY_COMMANDS.delete[1],
},
}]; }];
return [sectionMain, sectionDelete]; return [sectionMain, sectionDelete];
}, [ }, [

View File

@ -15,6 +15,7 @@ import clsx from 'clsx/lite';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import useKeydownHandler from '@/utility/useKeydownHandler'; import useKeydownHandler from '@/utility/useKeydownHandler';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { KEY_COMMANDS } from '@/photo/key-commands';
export type SwitcherSelection = 'feed' | 'grid' | 'admin'; export type SwitcherSelection = 'feed' | 'grid' | 'admin';
@ -38,13 +39,13 @@ export default function AppViewSwitcher({
const onKeyDown = useCallback((e: KeyboardEvent) => { const onKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key.toLocaleUpperCase()) { switch (e.key.toLocaleUpperCase()) {
case 'F': case KEY_COMMANDS.feed:
if (pathname !== PATH_FEED_INFERRED) { refHrefFeed.current?.click(); } if (pathname !== PATH_FEED_INFERRED) { refHrefFeed.current?.click(); }
break; break;
case 'G': case KEY_COMMANDS.grid:
if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); } if (pathname !== PATH_GRID_INFERRED) { refHrefGrid.current?.click(); }
break; break;
case 'A': case KEY_COMMANDS.admin:
if (isUserSignedIn) { setIsAdminMenuOpen(true); } if (isUserSignedIn) { setIsAdminMenuOpen(true); }
break; break;
} }
@ -61,7 +62,7 @@ export default function AppViewSwitcher({
active={currentSelection === 'feed'} active={currentSelection === 'feed'}
tooltip={{ tooltip={{
content: 'Feed', content: 'Feed',
keyCommand: 'F', keyCommand: KEY_COMMANDS.feed,
}} }}
noPadding noPadding
/>; />;
@ -74,7 +75,7 @@ export default function AppViewSwitcher({
active={currentSelection === 'grid'} active={currentSelection === 'grid'}
tooltip={{ tooltip={{
content: 'Grid', content: 'Grid',
keyCommand: 'G', keyCommand: KEY_COMMANDS.grid,
}} }}
noPadding noPadding
/>; />;
@ -105,7 +106,7 @@ export default function AppViewSwitcher({
/>} />}
tooltip={{ tooltip={{
content: !isAdminMenuOpen ? 'Admin Menu' : undefined, content: !isAdminMenuOpen ? 'Admin Menu' : undefined,
keyCommand: !isAdminMenuOpen ? 'A' : undefined, keyCommand: !isAdminMenuOpen ? KEY_COMMANDS.admin : undefined,
}} }}
noPadding noPadding
/>} />}
@ -116,8 +117,8 @@ export default function AppViewSwitcher({
onClick={() => setIsCommandKOpen?.(true)} onClick={() => setIsCommandKOpen?.(true)}
tooltip={{ tooltip={{
content: 'Search', content: 'Search',
keyCommand: 'K', keyCommandModifier: KEY_COMMANDS.search[0],
keyCommandModifier: '⌘', keyCommand: KEY_COMMANDS.search[1],
}} }}
/> />
</Switcher> </Switcher>

View File

@ -1,5 +1,7 @@
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { HiMiniBackspace } from 'react-icons/hi2';
import { PiCommandBold } from 'react-icons/pi';
export default function KeyCommand({ export default function KeyCommand({
children, children,
@ -10,9 +12,12 @@ export default function KeyCommand({
modifier?: '⌘' | '⌥' | '⇧' | '⌃' | '⏎' modifier?: '⌘' | '⌥' | '⇧' | '⌃' | '⏎'
className?: string className?: string
}) { }) {
const keys = useMemo(() => const keys = useMemo(() => {
modifier ? [modifier, ...children] : [...children], const childrenFormatted = children === 'BACKSPACE'
[modifier, children]); ? '⌫'
: children;
return modifier ? [modifier, ...childrenFormatted] : [...childrenFormatted];
}, [modifier, children]);
return ( return (
<span className={clsx('inline-flex items-center gap-0.5', className)}> <span className={clsx('inline-flex items-center gap-0.5', className)}>
@ -20,12 +25,17 @@ export default function KeyCommand({
<span <span
key={key} key={key}
className={clsx( className={clsx(
'px-1 rounded-sm shadow-xs', 'inline-flex items-center justify-center',
'text-gray-600 bg-gray-200/90', 'px-1 h-4 rounded-sm text-xs font-medium',
'dark:text-gray-300 dark:bg-gray-600/55', 'text-gray-500/90 bg-gray-200/70',
'dark:text-gray-300/90 dark:bg-gray-600/50',
)} )}
> >
{key} {key === '⌘'
? <PiCommandBold />
: key === '⌫'
? <HiMiniBackspace className="text-[13px]" />
: key}
</span> </span>
))} ))}
</span> </span>

View File

@ -139,6 +139,7 @@ export default function PhotoDetailPage({
shouldShareRecipe={recipe !== undefined} shouldShareRecipe={recipe !== undefined}
shouldShareFocalLength={focal !== undefined} shouldShareFocalLength={focal !== undefined}
includeFavoriteInAdminMenu={includeFavoriteInAdminMenu} includeFavoriteInAdminMenu={includeFavoriteInAdminMenu}
showAdminKeyCommands
/>, />,
]} ]}
/> />

View File

@ -12,7 +12,7 @@ import ShareButton from '@/share/ShareButton';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid'; import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import PhotoPrevNext from './PhotoPrevNext'; import PhotoPrevNextActions from './PhotoPrevNextActions';
import PhotoLink from './PhotoLink'; import PhotoLink from './PhotoLink';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
@ -59,7 +59,7 @@ export default function PhotoHeader({
: 'photo-detail'; : 'photo-detail';
const renderPrevNext = const renderPrevNext =
<PhotoPrevNext {...{ <PhotoPrevNextActions {...{
photo: selectedPhoto, photo: selectedPhoto,
photos, photos,
...categories, ...categories,

View File

@ -76,6 +76,7 @@ export default function PhotoLarge({
shouldShareFocalLength, shouldShareFocalLength,
includeFavoriteInAdminMenu, includeFavoriteInAdminMenu,
onVisible, onVisible,
showAdminKeyCommands,
}: { }: {
photo: Photo photo: Photo
className?: string className?: string
@ -101,6 +102,7 @@ export default function PhotoLarge({
shouldShareFocalLength?: boolean shouldShareFocalLength?: boolean
includeFavoriteInAdminMenu?: boolean includeFavoriteInAdminMenu?: boolean
onVisible?: () => void onVisible?: () => void
showAdminKeyCommands?: boolean
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const refZoomControls = useRef<ZoomControlsRef>(null); const refZoomControls = useRef<ZoomControlsRef>(null);
@ -252,7 +254,7 @@ export default function PhotoLarge({
revalidatePhoto, revalidatePhoto,
includeFavorite: includeFavoriteInAdminMenu, includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`, ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
showKeyCommands: true, showKeyCommands: showAdminKeyCommands,
}} />; }} />;
const largePhotoContainerClassName = clsx( const largePhotoContainerClassName = clsx(

View File

@ -16,17 +16,22 @@ import { clsx } from 'clsx/lite';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi'; import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import useNavigateOrRunActionWithToast import useNavigateOrRunActionWithToast
from '@/components/useNavigateOrRunActionWithToast'; from '@/components/useNavigateOrRunActionWithToast';
import { toggleFavoritePhotoAction } from './actions'; import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
} from './actions';
import { isPhotoFav } from '@/tag'; import { isPhotoFav } from '@/tag';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import { ALLOW_PUBLIC_DOWNLOADS } from '@/app/config'; import { ALLOW_PUBLIC_DOWNLOADS } from '@/app/config';
import { downloadFileFromBrowser } from '@/utility/url'; import { downloadFileFromBrowser } from '@/utility/url';
import useKeydownHandler from '@/utility/useKeydownHandler'; import useKeydownHandler from '@/utility/useKeydownHandler';
import { KEY_COMMANDS } from './key-commands';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 }; const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 }; const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
export default function PhotoPrevNext({ export default function PhotoPrevNextActions({
photo, photo,
photos = [], photos = [],
className, className,
@ -49,9 +54,7 @@ export default function PhotoPrevNext({
: undefined; : undefined;
const toggleFavorite = useCallback(() => { const toggleFavorite = useCallback(() => {
if (photo?.id) { if (photo?.id) { return toggleFavoritePhotoAction(photo.id); }
return toggleFavoritePhotoAction(photo.id);
}
}, [photo?.id]); }, [photo?.id]);
const navigateToPhotoEdit = useNavigateOrRunActionWithToast({ const navigateToPhotoEdit = useNavigateOrRunActionWithToast({
@ -69,6 +72,22 @@ export default function PhotoPrevNext({
toastMessage: `Unfavoriting ${photoTitle} ...`, toastMessage: `Unfavoriting ${photoTitle} ...`,
}); });
const syncPhoto = useNavigateOrRunActionWithToast({
pathOrAction: useCallback(() => {
if (photo?.id) { return syncPhotoAction(photo.id); }
}, [photo?.id]),
toastMessage: `Syncing ${photoTitle} ...`,
});
const deletePhoto = useNavigateOrRunActionWithToast({
pathOrAction: useCallback(() => {
if (photo?.id && photo.url) {
return deletePhotoAction(photo.id, photo.url, true);
}
}, [photo?.id, photo?.url]),
toastMessage: `Deleting ${photoTitle} ...`,
});
const refPrevious = useRef<HTMLAnchorElement | null>(null); const refPrevious = useRef<HTMLAnchorElement | null>(null);
const refNext = useRef<HTMLAnchorElement | null>(null); const refNext = useRef<HTMLAnchorElement | null>(null);
@ -85,36 +104,36 @@ export default function PhotoPrevNext({
const onKeyDown = useCallback((e: KeyboardEvent) => { const onKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key.toUpperCase()) { switch (e.key.toUpperCase()) {
case 'ARROWLEFT': case KEY_COMMANDS.prev[0]:
case 'J': case KEY_COMMANDS.prev[1]:
if (pathPrevious) { if (pathPrevious) {
setNextPhotoAnimation?.(ANIMATION_RIGHT); setNextPhotoAnimation?.(ANIMATION_RIGHT);
refPrevious.current?.click(); refPrevious.current?.click();
} }
break; break;
case 'ARROWRIGHT': case KEY_COMMANDS.next[0]:
case 'L': case KEY_COMMANDS.next[1]:
if (pathNext) { if (pathNext) {
setNextPhotoAnimation?.(ANIMATION_LEFT); setNextPhotoAnimation?.(ANIMATION_LEFT);
refNext.current?.click(); refNext.current?.click();
} }
break; break;
case 'E': case KEY_COMMANDS.edit:
if (isUserSignedIn) { if (isUserSignedIn) {
navigateToPhotoEdit(); navigateToPhotoEdit();
} }
break; break;
case 'P': case KEY_COMMANDS.favorite:
if (isUserSignedIn && photo && !isPhotoFav(photo)) { if (isUserSignedIn && photo && !isPhotoFav(photo)) {
favoritePhoto(); favoritePhoto();
} }
break; break;
case 'X': case KEY_COMMANDS.unfavorite:
if (isUserSignedIn && photo && isPhotoFav(photo)) { if (isUserSignedIn && photo && isPhotoFav(photo)) {
unfavoritePhoto(); unfavoritePhoto();
} }
break; break;
case 'D': case KEY_COMMANDS.download:
if ( if (
(isUserSignedIn || ALLOW_PUBLIC_DOWNLOADS) && (isUserSignedIn || ALLOW_PUBLIC_DOWNLOADS) &&
downloadUrl && downloadUrl &&
@ -123,6 +142,16 @@ export default function PhotoPrevNext({
downloadFileFromBrowser(downloadUrl, downloadFileName); downloadFileFromBrowser(downloadUrl, downloadFileName);
} }
break; break;
case KEY_COMMANDS.sync:
if (isUserSignedIn) {
syncPhoto();
}
break;
case KEY_COMMANDS.delete[1]:
if (e.metaKey && isUserSignedIn) {
deletePhoto();
}
break;
}; };
}, [ }, [
setNextPhotoAnimation, setNextPhotoAnimation,
@ -135,6 +164,8 @@ export default function PhotoPrevNext({
unfavoritePhoto, unfavoritePhoto,
downloadUrl, downloadUrl,
downloadFileName, downloadFileName,
syncPhoto,
deletePhoto,
]); ]);
useKeydownHandler({ onKeyDown }); useKeydownHandler({ onKeyDown });
@ -152,7 +183,7 @@ export default function PhotoPrevNext({
)}> )}>
<Tooltip <Tooltip
content={previousPhoto ? 'Previous' : undefined} content={previousPhoto ? 'Previous' : undefined}
keyCommand="J" keyCommand={previousPhoto ? KEY_COMMANDS.prev[0] : undefined}
> >
<PhotoLink <PhotoLink
{...categories} {...categories}
@ -172,7 +203,7 @@ export default function PhotoPrevNext({
</span> </span>
<Tooltip <Tooltip
content={nextPhoto ? 'Next' : undefined} content={nextPhoto ? 'Next' : undefined}
keyCommand="L" keyCommand={nextPhoto ? KEY_COMMANDS.next[0] : undefined}
> >
<PhotoLink <PhotoLink
{...categories} {...categories}

14
src/photo/key-commands.ts Normal file
View File

@ -0,0 +1,14 @@
export const KEY_COMMANDS = {
feed: 'F',
grid: 'G',
admin: 'A',
prev: ['J', 'ARROWLEFT'],
next: ['L', 'ARROWRIGHT'],
edit: 'E',
favorite: 'P',
unfavorite: 'X',
download: 'D',
sync: 'S',
search: ['⌘', 'K'],
delete: ['⌘', 'BACKSPACE'],
} as const;