Introduce keys commands: e, p, x
This commit is contained in:
parent
dfa1e1836f
commit
d5a66290c3
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -20,6 +20,7 @@
|
||||
"exif",
|
||||
"exiftool",
|
||||
"favicons",
|
||||
"Favoriting",
|
||||
"favs",
|
||||
"ghijklmnopqrstuv",
|
||||
"GPSH",
|
||||
@ -53,6 +54,7 @@
|
||||
"thephotoblog",
|
||||
"trpc",
|
||||
"Turbopack",
|
||||
"Unfavoriting",
|
||||
"unnest",
|
||||
"upstash",
|
||||
"UsKSGcbt",
|
||||
|
||||
58
src/components/useNavigateOrRunActionWithToast.ts
Normal file
58
src/components/useNavigateOrRunActionWithToast.ts
Normal 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;
|
||||
}
|
||||
@ -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: () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user