Introduce keys commands: e, p, x

This commit is contained in:
Sam Becker 2025-04-26 15:57:12 -05:00
parent dfa1e1836f
commit d5a66290c3
7 changed files with 141 additions and 14 deletions

View File

@ -20,6 +20,7 @@
"exif",
"exiftool",
"favicons",
"Favoriting",
"favs",
"ghijklmnopqrstuv",
"GPSH",
@ -53,6 +54,7 @@
"thephotoblog",
"trpc",
"Turbopack",
"Unfavoriting",
"unnest",
"upstash",
"UsKSGcbt",

View File

@ -0,0 +1,58 @@
import { toastWaiting } from '@/toast';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useTransition } from 'react';
import { toast } from 'sonner';
export default function useNavigateOrRunActionWithToast({
pathOrAction,
toastMessage = 'Loading...',
dismissDelay = 500,
}: {
pathOrAction?: string | (() => Promise<any> | undefined)
toastMessage?: string
dismissDelay?: number
}) {
const router = useRouter();
const toastId = useRef<string | number>(undefined);
const [isPending, startTransition] = useTransition();
useEffect(() => {
const dismissToast = () => {
if (toastId.current) {
return setTimeout(() => {
toast.dismiss(toastId.current);
}, dismissDelay);
}
};
if (!isPending) {
const timeout = dismissToast();
return () => clearTimeout(timeout);
}
return () => {
dismissToast();
};
}, [isPending, dismissDelay]);
const navigateOrRunAction = useCallback(() => {
if (typeof pathOrAction === 'string') {
startTransition(() => {
router.push(pathOrAction);
toastId.current = toastWaiting(toastMessage);
});
} else if (typeof pathOrAction === 'function') {
const result = pathOrAction();
if (result instanceof Promise) {
const toastId = toastWaiting(toastMessage);
result.finally(() => {
setTimeout(() => {
toast.dismiss(toastId);
}, 1000);
});
}
}
}, [pathOrAction, router, toastMessage]);
return navigateOrRunAction;
}

View File

@ -1,6 +1,6 @@
'use client';
import { ReactNode, ComponentProps } from 'react';
import { ReactNode, ComponentProps, RefObject } from 'react';
import { Photo, titleForPhoto } from '@/photo';
import { PhotoSetCategory } from '@/category';
import { AnimationConfig } from '../components/AnimateItems';
@ -12,6 +12,7 @@ import Spinner from '@/components/Spinner';
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
export default function PhotoLink({
ref,
photo,
scroll,
prefetch,
@ -21,6 +22,7 @@ export default function PhotoLink({
loaderType = 'spinner',
...categories
}: {
ref?: RefObject<HTMLAnchorElement | null>
photo?: Photo
scroll?: boolean
prefetch?: boolean
@ -35,6 +37,7 @@ export default function PhotoLink({
Omit<ComponentProps<typeof LinkWithStatus>, 'children'> |
undefined = photo
? {
ref,
className,
href: pathForPhoto({ photo, ...categories }),
onClick: () => {

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import {
Photo,
getNextPhoto,
@ -8,12 +8,15 @@ import {
} from '@/photo';
import { PhotoSetCategory } from '../category';
import PhotoLink from './PhotoLink';
import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/app/paths';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import { clsx } from 'clsx/lite';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import useNavigateOrRunActionWithToast
from '@/components/useNavigateOrRunActionWithToast';
import { toggleFavoritePhotoAction } from './actions';
import { isPhotoFav } from '@/tag';
const LISTENER_KEYUP = 'keyup';
@ -30,13 +33,42 @@ export default function PhotoPrevNext({
photos?: Photo[]
className?: string
} & PhotoSetCategory) {
const router = useRouter();
const {
setNextPhotoAnimation,
shouldRespondToKeyboardCommands,
isUserSignedIn,
} = useAppState();
const photoTitle = photo
? photo.title
? `'${photo.title}'`
: 'photo'
: undefined;
const toggleFavorite = useCallback(() => {
if (photo?.id) {
return toggleFavoritePhotoAction(photo.id);
}
}, [photo?.id]);
const navigateToPhotoEdit = useNavigateOrRunActionWithToast({
pathOrAction: photo ? pathForAdminPhotoEdit(photo) : undefined,
toastMessage: `Editing ${photoTitle} ...`,
});
const favoritePhoto = useNavigateOrRunActionWithToast({
pathOrAction: toggleFavorite,
toastMessage: `Favoriting ${photoTitle} ...`,
});
const unfavoritePhoto = useNavigateOrRunActionWithToast({
pathOrAction: toggleFavorite,
toastMessage: `Unfavoriting ${photoTitle} ...`,
});
const refPrevious = useRef<HTMLAnchorElement | null>(null);
const refNext = useRef<HTMLAnchorElement | null>(null);
const previousPhoto = photo ? getPreviousPhoto(photo, photos) : undefined;
const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
@ -56,14 +88,27 @@ export default function PhotoPrevNext({
case 'J':
if (pathPrevious) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
router.push(pathPrevious, { scroll: false });
refPrevious.current?.click();
}
break;
case 'ARROWRIGHT':
case 'L':
if (pathNext) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
router.push(pathNext, { scroll: false });
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;
};
@ -72,11 +117,16 @@ export default function PhotoPrevNext({
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
}
}, [
router,
shouldRespondToKeyboardCommands,
setNextPhotoAnimation,
pathPrevious,
pathNext,
isUserSignedIn,
photoTitle,
navigateToPhotoEdit,
photo,
favoritePhoto,
unfavoritePhoto,
]);
return (
@ -93,6 +143,7 @@ export default function PhotoPrevNext({
)}>
<PhotoLink
{...categories}
ref={refPrevious}
photo={previousPhoto}
nextPhotoAnimation={ANIMATION_RIGHT}
scroll={false}
@ -107,6 +158,7 @@ export default function PhotoPrevNext({
</span>
<PhotoLink
{...categories}
ref={refNext}
photo={nextPhoto}
nextPhotoAnimation={ANIMATION_LEFT}
scroll={false}

View File

@ -44,7 +44,7 @@ import {
extractImageDataFromBlobPath,
propagateRecipeTitleIfNecessary,
} from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { TAG_FAVS, isPhotoFav, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery } from './ai';
@ -254,7 +254,7 @@ export const toggleFavoritePhotoAction = async (
const photo = await getPhoto(photoId);
if (photo) {
const { tags } = photo;
photo.tags = tags.some(tag => tag === TAG_FAVS)
photo.tags = isPhotoFav(photo)
? tags.filter(tag => !isTagFavs(tag))
: [...tags, TAG_FAVS];
await updatePhoto(convertPhotoToPhotoDbInsert(photo));

View File

@ -213,17 +213,18 @@ export const translatePhotoId = (id: string) =>
export const titleForPhoto = (
photo: Photo,
preferDateOverUntitled = true,
useDateAsTitle = true,
fallback = 'Untitled',
) => {
if (photo.title) {
return photo.title;
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
} else if (useDateAsTitle && (photo.takenAt || photo.createdAt)) {
return formatDate({
date: photo.takenAt || photo.createdAt,
length: 'tiny',
}).toLocaleUpperCase();
} else {
return 'Untitled';
return fallback;
}
};

View File

@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { PiWarningBold } from 'react-icons/pi';
import { FiCheckSquare } from 'react-icons/fi';
import { toast } from 'sonner';
import Spinner from '@/components/Spinner';
const DEFAULT_DURATION = 4000;
@ -24,3 +25,13 @@ export const toastWarning = (
duration,
},
);
export const toastWaiting = (
message: ReactNode,
duration = Infinity,
) => toast(
message, {
icon: <Spinner size={16} />,
duration,
},
);