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",
|
"exif",
|
||||||
"exiftool",
|
"exiftool",
|
||||||
"favicons",
|
"favicons",
|
||||||
|
"Favoriting",
|
||||||
"favs",
|
"favs",
|
||||||
"ghijklmnopqrstuv",
|
"ghijklmnopqrstuv",
|
||||||
"GPSH",
|
"GPSH",
|
||||||
@ -53,6 +54,7 @@
|
|||||||
"thephotoblog",
|
"thephotoblog",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Turbopack",
|
"Turbopack",
|
||||||
|
"Unfavoriting",
|
||||||
"unnest",
|
"unnest",
|
||||||
"upstash",
|
"upstash",
|
||||||
"UsKSGcbt",
|
"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';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode, ComponentProps } from 'react';
|
import { ReactNode, ComponentProps, RefObject } from 'react';
|
||||||
import { Photo, titleForPhoto } from '@/photo';
|
import { Photo, titleForPhoto } from '@/photo';
|
||||||
import { PhotoSetCategory } from '@/category';
|
import { PhotoSetCategory } from '@/category';
|
||||||
import { AnimationConfig } from '../components/AnimateItems';
|
import { AnimationConfig } from '../components/AnimateItems';
|
||||||
@ -12,6 +12,7 @@ import Spinner from '@/components/Spinner';
|
|||||||
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
|
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
|
||||||
|
|
||||||
export default function PhotoLink({
|
export default function PhotoLink({
|
||||||
|
ref,
|
||||||
photo,
|
photo,
|
||||||
scroll,
|
scroll,
|
||||||
prefetch,
|
prefetch,
|
||||||
@ -21,6 +22,7 @@ export default function PhotoLink({
|
|||||||
loaderType = 'spinner',
|
loaderType = 'spinner',
|
||||||
...categories
|
...categories
|
||||||
}: {
|
}: {
|
||||||
|
ref?: RefObject<HTMLAnchorElement | null>
|
||||||
photo?: Photo
|
photo?: Photo
|
||||||
scroll?: boolean
|
scroll?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
@ -35,6 +37,7 @@ export default function PhotoLink({
|
|||||||
Omit<ComponentProps<typeof LinkWithStatus>, 'children'> |
|
Omit<ComponentProps<typeof LinkWithStatus>, 'children'> |
|
||||||
undefined = photo
|
undefined = photo
|
||||||
? {
|
? {
|
||||||
|
ref,
|
||||||
className,
|
className,
|
||||||
href: pathForPhoto({ photo, ...categories }),
|
href: pathForPhoto({ photo, ...categories }),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Photo,
|
Photo,
|
||||||
getNextPhoto,
|
getNextPhoto,
|
||||||
@ -8,12 +8,15 @@ import {
|
|||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import PhotoLink from './PhotoLink';
|
import PhotoLink from './PhotoLink';
|
||||||
import { useRouter } from 'next/navigation';
|
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
||||||
import { pathForPhoto } from '@/app/paths';
|
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||||
|
import useNavigateOrRunActionWithToast
|
||||||
|
from '@/components/useNavigateOrRunActionWithToast';
|
||||||
|
import { toggleFavoritePhotoAction } from './actions';
|
||||||
|
import { isPhotoFav } from '@/tag';
|
||||||
|
|
||||||
const LISTENER_KEYUP = 'keyup';
|
const LISTENER_KEYUP = 'keyup';
|
||||||
|
|
||||||
@ -30,13 +33,42 @@ export default function PhotoPrevNext({
|
|||||||
photos?: Photo[]
|
photos?: Photo[]
|
||||||
className?: string
|
className?: string
|
||||||
} & PhotoSetCategory) {
|
} & PhotoSetCategory) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setNextPhotoAnimation,
|
setNextPhotoAnimation,
|
||||||
shouldRespondToKeyboardCommands,
|
shouldRespondToKeyboardCommands,
|
||||||
|
isUserSignedIn,
|
||||||
} = useAppState();
|
} = 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 previousPhoto = photo ? getPreviousPhoto(photo, photos) : undefined;
|
||||||
const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
|
const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
|
||||||
|
|
||||||
@ -56,14 +88,27 @@ export default function PhotoPrevNext({
|
|||||||
case 'J':
|
case 'J':
|
||||||
if (pathPrevious) {
|
if (pathPrevious) {
|
||||||
setNextPhotoAnimation?.(ANIMATION_RIGHT);
|
setNextPhotoAnimation?.(ANIMATION_RIGHT);
|
||||||
router.push(pathPrevious, { scroll: false });
|
refPrevious.current?.click();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ARROWRIGHT':
|
case 'ARROWRIGHT':
|
||||||
case 'L':
|
case 'L':
|
||||||
if (pathNext) {
|
if (pathNext) {
|
||||||
setNextPhotoAnimation?.(ANIMATION_LEFT);
|
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;
|
break;
|
||||||
};
|
};
|
||||||
@ -72,11 +117,16 @@ export default function PhotoPrevNext({
|
|||||||
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
router,
|
|
||||||
shouldRespondToKeyboardCommands,
|
shouldRespondToKeyboardCommands,
|
||||||
setNextPhotoAnimation,
|
setNextPhotoAnimation,
|
||||||
pathPrevious,
|
pathPrevious,
|
||||||
pathNext,
|
pathNext,
|
||||||
|
isUserSignedIn,
|
||||||
|
photoTitle,
|
||||||
|
navigateToPhotoEdit,
|
||||||
|
photo,
|
||||||
|
favoritePhoto,
|
||||||
|
unfavoritePhoto,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -93,6 +143,7 @@ export default function PhotoPrevNext({
|
|||||||
)}>
|
)}>
|
||||||
<PhotoLink
|
<PhotoLink
|
||||||
{...categories}
|
{...categories}
|
||||||
|
ref={refPrevious}
|
||||||
photo={previousPhoto}
|
photo={previousPhoto}
|
||||||
nextPhotoAnimation={ANIMATION_RIGHT}
|
nextPhotoAnimation={ANIMATION_RIGHT}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
@ -107,6 +158,7 @@ export default function PhotoPrevNext({
|
|||||||
</span>
|
</span>
|
||||||
<PhotoLink
|
<PhotoLink
|
||||||
{...categories}
|
{...categories}
|
||||||
|
ref={refNext}
|
||||||
photo={nextPhoto}
|
photo={nextPhoto}
|
||||||
nextPhotoAnimation={ANIMATION_LEFT}
|
nextPhotoAnimation={ANIMATION_LEFT}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ import {
|
|||||||
extractImageDataFromBlobPath,
|
extractImageDataFromBlobPath,
|
||||||
propagateRecipeTitleIfNecessary,
|
propagateRecipeTitleIfNecessary,
|
||||||
} from './server';
|
} from './server';
|
||||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
import { TAG_FAVS, isPhotoFav, isTagFavs } from '@/tag';
|
||||||
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
||||||
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||||
import { AiImageQuery, getAiImageQuery } from './ai';
|
import { AiImageQuery, getAiImageQuery } from './ai';
|
||||||
@ -254,7 +254,7 @@ export const toggleFavoritePhotoAction = async (
|
|||||||
const photo = await getPhoto(photoId);
|
const photo = await getPhoto(photoId);
|
||||||
if (photo) {
|
if (photo) {
|
||||||
const { tags } = photo;
|
const { tags } = photo;
|
||||||
photo.tags = tags.some(tag => tag === TAG_FAVS)
|
photo.tags = isPhotoFav(photo)
|
||||||
? tags.filter(tag => !isTagFavs(tag))
|
? tags.filter(tag => !isTagFavs(tag))
|
||||||
: [...tags, TAG_FAVS];
|
: [...tags, TAG_FAVS];
|
||||||
await updatePhoto(convertPhotoToPhotoDbInsert(photo));
|
await updatePhoto(convertPhotoToPhotoDbInsert(photo));
|
||||||
|
|||||||
@ -213,17 +213,18 @@ export const translatePhotoId = (id: string) =>
|
|||||||
|
|
||||||
export const titleForPhoto = (
|
export const titleForPhoto = (
|
||||||
photo: Photo,
|
photo: Photo,
|
||||||
preferDateOverUntitled = true,
|
useDateAsTitle = true,
|
||||||
|
fallback = 'Untitled',
|
||||||
) => {
|
) => {
|
||||||
if (photo.title) {
|
if (photo.title) {
|
||||||
return photo.title;
|
return photo.title;
|
||||||
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
|
} else if (useDateAsTitle && (photo.takenAt || photo.createdAt)) {
|
||||||
return formatDate({
|
return formatDate({
|
||||||
date: photo.takenAt || photo.createdAt,
|
date: photo.takenAt || photo.createdAt,
|
||||||
length: 'tiny',
|
length: 'tiny',
|
||||||
}).toLocaleUpperCase();
|
}).toLocaleUpperCase();
|
||||||
} else {
|
} else {
|
||||||
return 'Untitled';
|
return fallback;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { PiWarningBold } from 'react-icons/pi';
|
import { PiWarningBold } from 'react-icons/pi';
|
||||||
import { FiCheckSquare } from 'react-icons/fi';
|
import { FiCheckSquare } from 'react-icons/fi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import Spinner from '@/components/Spinner';
|
||||||
|
|
||||||
const DEFAULT_DURATION = 4000;
|
const DEFAULT_DURATION = 4000;
|
||||||
|
|
||||||
@ -24,3 +25,13 @@ export const toastWarning = (
|
|||||||
duration,
|
duration,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const toastWaiting = (
|
||||||
|
message: ReactNode,
|
||||||
|
duration = Infinity,
|
||||||
|
) => toast(
|
||||||
|
message, {
|
||||||
|
icon: <Spinner size={16} />,
|
||||||
|
duration,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user