From edb4df83b8506cf2bed526a4ffed13dd127301e7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 26 Apr 2024 18:42:00 -0500 Subject: [PATCH] Introduce timestamp-based swr invalidation --- src/admin/AdminPhotoTable.tsx | 103 ++++++++++++++++++++ src/admin/{AdminGrid.tsx => AdminTable.tsx} | 2 +- src/admin/AdminTagBadge.tsx | 40 ++++++++ src/admin/AdminTagTable.tsx | 48 +++++++++ src/admin/DeleteButton.tsx | 6 +- src/admin/StorageUrls.tsx | 6 +- src/app/admin/photos/page.tsx | 94 +----------------- src/app/admin/tags/[tag]/edit/page.tsx | 23 +---- src/app/admin/tags/page.tsx | 42 +------- src/app/grid/page.tsx | 1 - src/app/page.tsx | 1 - src/components/SubmitButtonWithStatus.tsx | 10 +- src/photo/InfinitePhotoScroll.tsx | 19 ++-- src/photo/PhotoGrid.tsx | 3 +- src/photo/form/PhotoForm.tsx | 15 ++- src/site/Nav.tsx | 9 +- src/state/AppState.ts | 2 + src/state/AppStateProvider.tsx | 6 ++ src/tag/TagForm.tsx | 4 + 19 files changed, 251 insertions(+), 183 deletions(-) create mode 100644 src/admin/AdminPhotoTable.tsx rename src/admin/{AdminGrid.tsx => AdminTable.tsx} (93%) create mode 100644 src/admin/AdminTagBadge.tsx create mode 100644 src/admin/AdminTagTable.tsx diff --git a/src/admin/AdminPhotoTable.tsx b/src/admin/AdminPhotoTable.tsx new file mode 100644 index 00000000..e5527ce0 --- /dev/null +++ b/src/admin/AdminPhotoTable.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { Photo, deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo'; +import AdminTable from './AdminTable'; +import { Fragment } from 'react'; +import PhotoTiny from '@/photo/PhotoTiny'; +import { clsx } from 'clsx/lite'; +import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths'; +import Link from 'next/link'; +import { AiOutlineEyeInvisible } from 'react-icons/ai'; +import PhotoDate from '@/photo/PhotoDate'; +import FormWithConfirm from '@/components/FormWithConfirm'; +import EditButton from './EditButton'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import IconGrSync from '@/site/IconGrSync'; +import DeleteButton from './DeleteButton'; +import { + deletePhotoFormAction, + syncPhotoExifDataAction, +} from '@/photo/actions'; +import { useAppState } from '@/state/AppState'; + +export default function AdminPhotoTable({ + photos, +}: { + photos: Photo[], +}) { + const { invalidateSwr } = useAppState(); + + return ( + + {photos.map(photo => + + +
+ + + {photo.title || 'Untitled'} + {photo.hidden && + } + + {photo.priorityOrder !== null && + + {photo.priorityOrder} + } + +
+ +
+
+
+ + + + } + onFormSubmitToastMessage={` + "${titleForPhoto(photo)}" EXIF data synced + `} + onFormSubmit={invalidateSwr} + /> + + + + + + +
+
)} +
+ ); +} diff --git a/src/admin/AdminGrid.tsx b/src/admin/AdminTable.tsx similarity index 93% rename from src/admin/AdminGrid.tsx rename to src/admin/AdminTable.tsx index 2160f205..0b9780f9 100644 --- a/src/admin/AdminGrid.tsx +++ b/src/admin/AdminTable.tsx @@ -1,7 +1,7 @@ import { clsx } from 'clsx/lite'; import { ReactNode } from 'react'; -export default function AdminGrid ({ +export default function AdminTable ({ title, children, }: { diff --git a/src/admin/AdminTagBadge.tsx b/src/admin/AdminTagBadge.tsx new file mode 100644 index 00000000..7e4b2d30 --- /dev/null +++ b/src/admin/AdminTagBadge.tsx @@ -0,0 +1,40 @@ +import PhotoTag from '@/tag/PhotoTag'; +import { photoLabelForCount } from '@/photo'; +import { clsx } from 'clsx/lite'; +import FavsTag from '@/tag/FavsTag'; +import { isTagFavs } from '@/tag'; +import Badge from '@/components/Badge'; + +export default function AdminTagBadge({ + tag, + count, + hideBadge, +}: { + tag: string, + count: number, + hideBadge?: boolean, +}) { + const renderBadgeContent = () => +
*>*:first-child]:items-center', + )}> + {isTagFavs(tag) + ? + : } +
+ {count} + +   + {photoLabelForCount(count)} + +
+
; + + return ( + hideBadge + ? renderBadgeContent() + : {renderBadgeContent()} + ); +} \ No newline at end of file diff --git a/src/admin/AdminTagTable.tsx b/src/admin/AdminTagTable.tsx new file mode 100644 index 00000000..d772c00e --- /dev/null +++ b/src/admin/AdminTagTable.tsx @@ -0,0 +1,48 @@ +'use client'; + +import FormWithConfirm from '@/components/FormWithConfirm'; +import { deletePhotoTagGloballyAction } from '@/photo/actions'; +import AdminTable from '@/admin/AdminTable'; +import { Fragment } from 'react'; +import DeleteButton from '@/admin/DeleteButton'; +import { photoQuantityText } from '@/photo'; +import { TagsWithMeta, formatTag, sortTagsObject } from '@/tag'; +import EditButton from '@/admin/EditButton'; +import { pathForAdminTagEdit } from '@/site/paths'; +import { clsx } from 'clsx/lite'; +import AdminTagBadge from './AdminTagBadge'; +import { useAppState } from '@/state/AppState'; + +export default function AdminTagTable({ + tags, +}: { + tags: TagsWithMeta +}) { + const { invalidateSwr } = useAppState(); + + return ( + + {sortTagsObject(tags).map(({ tag, count }) => + +
+ +
+
+ + + + + +
+
)} +
+ ); +} diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 17815060..554abb4c 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -1,9 +1,13 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { clsx } from 'clsx/lite'; +import { ComponentProps } from 'react'; import { BiTrash } from 'react-icons/bi'; -export default function DeleteButton () { +export default function DeleteButton ( + props: ComponentProps +) { return } spinnerColor="text" diff --git a/src/admin/StorageUrls.tsx b/src/admin/StorageUrls.tsx index 22cd5ff4..9d808ef0 100644 --- a/src/admin/StorageUrls.tsx +++ b/src/admin/StorageUrls.tsx @@ -1,5 +1,5 @@ import { Fragment } from 'react'; -import AdminGrid from './AdminGrid'; +import AdminTable from './AdminTable'; import Link from 'next/link'; import ImageTiny from '@/components/ImageTiny'; import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage'; @@ -19,7 +19,7 @@ export default function StorageUrls({ urls: StorageListResponse }) { return ( - + {urls.map(({ url, uploadedAt }) => { const addUploadPath = pathForAdminUploadUrl(url); const uploadFileName = fileNameForStorageUrl(url); @@ -70,6 +70,6 @@ export default function StorageUrls({ ;})} - + ); } diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index dd3df34a..618e9996 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -1,39 +1,20 @@ -import { Fragment } from 'react'; import PhotoUpload from '@/photo/PhotoUpload'; -import Link from 'next/link'; -import PhotoTiny from '@/photo/PhotoTiny'; import { clsx } from 'clsx/lite'; -import FormWithConfirm from '@/components/FormWithConfirm'; import SiteGrid from '@/components/SiteGrid'; -import { - deletePhotoFormAction, - syncPhotoExifDataAction, -} from '@/photo/actions'; -import { - pathForAdminPhotos, - pathForPhoto, - pathForAdminPhotoEdit, -} from '@/site/paths'; -import { deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo'; +import { pathForAdminPhotos } from '@/site/paths'; import { getPhotosCountIncludingHiddenCached } from '@/photo/cache'; -import { AiOutlineEyeInvisible } from 'react-icons/ai'; import { PaginationParams, getPaginationFromSearchParams, } from '@/site/pagination'; -import AdminGrid from '@/admin/AdminGrid'; -import DeleteButton from '@/admin/DeleteButton'; -import EditButton from '@/admin/EditButton'; import StorageUrls from '@/admin/StorageUrls'; import { PRO_MODE_ENABLED } from '@/site/config'; -import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; -import IconGrSync from '@/site/IconGrSync'; import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache'; import MoreComponentsFromSearchParams from '@/components/MoreComponentsFromSearchParams'; import { getPhotos } from '@/services/vercel-postgres'; -import PhotoDate from '@/photo/PhotoDate'; import { revalidatePath } from 'next/cache'; +import AdminPhotoTable from '@/admin/AdminPhotoTable'; const DEBUG_PHOTO_BLOBS = false; @@ -77,76 +58,7 @@ export default async function AdminPhotosPage({ /> }
- - {photos.map(photo => - - -
- - - {photo.title || 'Untitled'} - {photo.hidden && - } - - {photo.priorityOrder !== null && - - {photo.priorityOrder} - } - -
- -
-
-
- - - - } - onFormSubmitToastMessage={` - "${titleForPhoto(photo)}" EXIF data synced - `} - /> - - - - - - -
-
)} -
+ {showMorePhotos && *>*:first-child]:items-center', - )}> - {isTagFavs(tag) - ? - : } -
- {count} - -   - {photoLabelForCount(count)} - -
-
} + breadcrumb={} >
- - {sortTagsObject(tags).map(({ tag, count }) => - -
- {isTagFavs(tag) - ? - : } -
-
- {photoQuantityText(count, false)} -
-
- - - - - -
-
)} -
+
} /> diff --git a/src/app/grid/page.tsx b/src/app/grid/page.tsx index 3a52c212..b9c8258c 100644 --- a/src/app/grid/page.tsx +++ b/src/app/grid/page.tsx @@ -38,7 +38,6 @@ export default async function GridPage() { {photos.length >= INFINITE_SCROLL_MULTIPLE_GRID && {photos.length >= INFINITE_SCROLL_MULTIPLE_HOME && { - if ( - pendingPrevious.current && - !pending && - onFormSubmitToastMessage - ) { - toastSuccess(onFormSubmitToastMessage); + if (pending && !pendingPrevious.current) { + if (onFormSubmitToastMessage) { + toastSuccess(onFormSubmitToastMessage); + } onFormSubmit?.(); } pendingPrevious.current = pending; diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index a478e2ca..4953645a 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -10,6 +10,7 @@ import { getPhotosAction } from '@/photo/actions'; import { Photo } from '.'; import PhotoGrid from './PhotoGrid'; import { clsx } from 'clsx/lite'; +import { useAppState } from '@/state/AppState'; export type RevalidatePhoto = ( photoId: string, @@ -17,14 +18,12 @@ export type RevalidatePhoto = ( ) => Promise; export default function InfinitePhotoScroll({ - swrKey, type = 'full-frame', initialOffset = 0, itemsPerPage = 12, prefetch = true, triggerOnView = true, }: { - swrKey: string type?: 'full-frame' | 'grid' initialOffset?: number itemsPerPage?: number @@ -32,9 +31,15 @@ export default function InfinitePhotoScroll({ triggerOnView?: boolean debug?: boolean }) { - const key = `${swrKey}-${type}`; + const { swrTimestamp } = useAppState(); - const buttonContainerRef = useRef(null); + const key = `${swrTimestamp}-${type}`; + + const keyGenerator = useCallback( + (size: number, prev: Photo[]) => prev && prev.length === 0 + ? null + : [key, size] + , [key]); const fetcher = useCallback(([_key, size]: [string, number]) => getPhotosAction( @@ -45,13 +50,13 @@ export default function InfinitePhotoScroll({ const { data, isLoading, isValidating, error, mutate, size, setSize } = useSwrInfinite( - (size: number, prev: []) => prev && prev.length === 0 - ? null - : [key, size], + keyGenerator, fetcher, { revalidateFirstPage: false }, ); + const buttonContainerRef = useRef(null); + const isLoadingOrValidating = isLoading || isValidating; const isFinished = useMemo(() => diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index c4015796..9b6e3e9b 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -72,7 +72,8 @@ export default function PhotoGrid({ priority: photoPriority, }} /> ).concat(additionalTile ?? [])} - itemKeys={photos.map(photo => photo.id)} + itemKeys={photos.map(photo => photo.id) + .concat(additionalTile ? ['more'] : [])} /> ); }; diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index fdf3e23a..1e14d861 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -29,7 +29,7 @@ import Spinner from '@/components/Spinner'; import { getNextImageUrlForRequest } from '@/services/next-image'; import useDelay from '@/utility/useDelay'; import usePreventNavigation from '@/utility/usePreventNavigation'; -import useSwrClear from '@/state/useSwrClear'; +import { useAppState } from '@/state/AppState'; const THUMBNAIL_SIZE = 300; @@ -63,6 +63,8 @@ export default function PhotoForm({ useState(); const [hasBlurData, setHasBlurData] = useState(false); + const { invalidateSwr } = useAppState(); + const changedFormKeys = useMemo(() => getChangedFormFields(initialPhotoForm, formData), [initialPhotoForm, formData]); @@ -215,8 +217,6 @@ export default function PhotoForm({ } }; - const clearSwr = useSwrClear(); - return (
{debugBlur && blurError && @@ -369,7 +369,14 @@ export default function PhotoForm({ {type === 'create' ? 'Create' : 'Update'} diff --git a/src/site/Nav.tsx b/src/site/Nav.tsx index 499e00a6..94b24ece 100644 --- a/src/site/Nav.tsx +++ b/src/site/Nav.tsx @@ -8,6 +8,7 @@ import { SITE_DOMAIN_OR_TITLE } from '@/site/config'; import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher'; import { PATH_ROOT, + isPathAdmin, isPathGrid, isPathProtected, isPathSignIn, @@ -15,11 +16,7 @@ import { import AnimateItems from '../components/AnimateItems'; import { useAppState } from '@/state/AppState'; -export default function Nav({ - animate = true, -}: { - animate?: boolean, -}) { +export default function Nav() { const pathname = usePathname(); const { isUserSignedIn } = useAppState(); @@ -49,7 +46,7 @@ export default function Nav({ contentMain={ void userEmail?: string setUserEmail?: Dispatch> isUserSignedIn?: boolean diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index fb416133..a186e686 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -15,6 +15,8 @@ export default function AppStateProvider({ const [hasLoaded, setHasLoaded] = useState(false); + const [swrTimestamp, setSwrTimestamp] = + useState(Date.now()); const [userEmail, setUserEmail] = useState(); const [nextPhotoAnimation, setNextPhotoAnimation] = @@ -26,6 +28,8 @@ export default function AppStateProvider({ const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = useState(false); + const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); + const captureUser = useCallback(() => getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined)) , []); @@ -40,6 +44,8 @@ export default function AppStateProvider({ value={{ previousPathname, hasLoaded, + swrTimestamp, + invalidateSwr, setHasLoaded, isUserSignedIn: userEmail !== undefined, userEmail, diff --git a/src/tag/TagForm.tsx b/src/tag/TagForm.tsx index 27d3ea35..30608287 100644 --- a/src/tag/TagForm.tsx +++ b/src/tag/TagForm.tsx @@ -7,6 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import { ReactNode, useMemo, useState } from 'react'; import { renamePhotoTagGloballyAction } from '@/photo/actions'; import { parameterize } from '@/utility/string'; +import { useAppState } from '@/state/AppState'; export default function TagForm({ tag, @@ -15,6 +16,8 @@ export default function TagForm({ tag: string children?: ReactNode }) { + const { invalidateSwr } = useAppState(); + const [updatedTagRaw, setUpdatedTagRaw] = useState(tag); const updatedTag = useMemo(() => @@ -61,6 +64,7 @@ export default function TagForm({ Update