diff --git a/.vscode/settings.json b/.vscode/settings.json index 2f8184b5..f041c11d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "exif", "exiftool", "favicons", + "Favoriting", "favs", "ghijklmnopqrstuv", "GPSH", @@ -53,6 +54,7 @@ "thephotoblog", "trpc", "Turbopack", + "Unfavoriting", "unnest", "upstash", "UsKSGcbt", diff --git a/src/components/useNavigateOrRunActionWithToast.ts b/src/components/useNavigateOrRunActionWithToast.ts new file mode 100644 index 00000000..d8bbb113 --- /dev/null +++ b/src/components/useNavigateOrRunActionWithToast.ts @@ -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 | undefined) + toastMessage?: string + dismissDelay?: number +}) { + const router = useRouter(); + + const toastId = useRef(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; +} diff --git a/src/photo/PhotoLink.tsx b/src/photo/PhotoLink.tsx index 2c091fbd..4fd5532d 100644 --- a/src/photo/PhotoLink.tsx +++ b/src/photo/PhotoLink.tsx @@ -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 photo?: Photo scroll?: boolean prefetch?: boolean @@ -35,6 +37,7 @@ export default function PhotoLink({ Omit, 'children'> | undefined = photo ? { + ref, className, href: pathForPhoto({ photo, ...categories }), onClick: () => { diff --git a/src/photo/PhotoPrevNext.tsx b/src/photo/PhotoPrevNext.tsx index 4ac73b50..f1486169 100644 --- a/src/photo/PhotoPrevNext.tsx +++ b/src/photo/PhotoPrevNext.tsx @@ -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(null); + const refNext = useRef(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({ )}> tag === TAG_FAVS) + photo.tags = isPhotoFav(photo) ? tags.filter(tag => !isTagFavs(tag)) : [...tags, TAG_FAVS]; await updatePhoto(convertPhotoToPhotoDbInsert(photo)); diff --git a/src/photo/index.ts b/src/photo/index.ts index a93c3675..e822feab 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -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; } }; diff --git a/src/toast/index.tsx b/src/toast/index.tsx index d453ca10..1935080b 100644 --- a/src/toast/index.tsx +++ b/src/toast/index.tsx @@ -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: , + duration, + }, +);