Introduce timestamp-based swr invalidation

This commit is contained in:
Sam Becker 2024-04-26 18:42:00 -05:00
parent 11878f807c
commit edb4df83b8
19 changed files with 251 additions and 183 deletions

View File

@ -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 (
<AdminTable>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="lg:w-[50%] flex items-center gap-2"
prefetch={false}
>
<span className={clsx(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
size={16}
/>}
</span>
{photo.priorityOrder !== null &&
<span className={clsx(
'text-xs leading-none px-1.5 py-1 rounded-sm',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={clsx(
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{ photo }} />
</div>
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={syncPhotoExifDataAction}
confirmText={
'Are you sure you want to overwrite EXIF data ' +
`for "${titleForPhoto(photo)}" from source file? ` +
'This action cannot be undone.'
}
>
<input type="hidden" name="id" value={photo.id} />
<SubmitButtonWithStatus
icon={<IconGrSync className="translate-y-[-0.5px]" />}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}
onFormSubmit={invalidateSwr}
/>
</FormWithConfirm>
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton onFormSubmit={invalidateSwr} />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -1,7 +1,7 @@
import { clsx } from 'clsx/lite';
import { ReactNode } from 'react';
export default function AdminGrid ({
export default function AdminTable ({
title,
children,
}: {

View File

@ -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 = () =>
<div className={clsx(
'inline-flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk for tags
'[&>*>*:first-child]:items-center',
)}>
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count)}
</span>
</div>
</div>;
return (
hideBadge
? renderBadgeContent()
: <Badge>{renderBadgeContent()}</Badge>
);
}

View File

@ -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 (
<AdminTable>
{sortTagsObject(tags).map(({ tag, count }) =>
<Fragment key={tag}>
<div className="pr-2 col-span-2">
<AdminTagBadge {...{ tag, count }} />
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton onFormSubmit={invalidateSwr} />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminTable>
);
}

View File

@ -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<typeof SubmitButtonWithStatus>
) {
return <SubmitButtonWithStatus
{...props}
title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
spinnerColor="text"

View File

@ -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 (
<AdminGrid {...{ title }} >
<AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url);
@ -70,6 +70,6 @@ export default function StorageUrls({
</FormWithConfirm>
</div>
</Fragment>;})}
</AdminGrid>
</AdminTable>
);
}

View File

@ -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({
/>
</div>}
<div className="space-y-4">
<AdminGrid>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny photo={photo} />
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}
href={pathForPhoto(photo)}
className="lg:w-[50%] flex items-center gap-2"
prefetch={false}
>
<span className={clsx(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim',
)}>
<span>{photo.title || 'Untitled'}</span>
{photo.hidden &&
<AiOutlineEyeInvisible
className="translate-y-[0.25px]"
size={16}
/>}
</span>
{photo.priorityOrder !== null &&
<span className={clsx(
'text-xs leading-none px-1.5 py-1 rounded-sm',
'dark:text-gray-300',
'bg-gray-100 dark:bg-gray-800',
)}>
{photo.priorityOrder}
</span>}
</Link>
<div className={clsx(
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{ photo }} />
</div>
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminPhotoEdit(photo)} />
<FormWithConfirm
action={syncPhotoExifDataAction}
confirmText={
'Are you sure you want to overwrite EXIF data ' +
`for "${titleForPhoto(photo)}" from source file? ` +
'This action cannot be undone.'
}
>
<input type="hidden" name="id" value={photo.id} />
<SubmitButtonWithStatus
icon={<IconGrSync className="translate-y-[-0.5px]" />}
onFormSubmitToastMessage={`
"${titleForPhoto(photo)}" EXIF data synced
`}
/>
</FormWithConfirm>
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminGrid>
<AdminPhotoTable photos={photos} />
{showMorePhotos &&
<MoreComponentsFromSearchParams
label="More photos"

View File

@ -3,13 +3,9 @@ import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths';
import PhotoTag from '@/tag/PhotoTag';
import { photoLabelForCount } from '@/photo';
import PhotoLightbox from '@/photo/PhotoLightbox';
import FavsTag from '@/tag/FavsTag';
import { isTagFavs } from '@/tag';
import { getPhotosTagMeta } from '@/services/vercel-postgres';
import { clsx } from 'clsx/lite';
import AdminTagBadge from '@/admin/AdminTagBadge';
const MAX_PHOTO_TO_SHOW = 6;
@ -36,22 +32,7 @@ export default async function PhotoPageEdit({
<AdminChildPage
backPath={PATH_ADMIN_TAGS}
backLabel="Tags"
breadcrumb={<div className={clsx(
'flex items-center gap-2',
// Fix nested EntityLink-in-Badge quirk for tags
'[&>*>*:first-child]:items-center',
)}>
{isTagFavs(tag)
? <FavsTag />
: <PhotoTag {...{ tag }} />}
<div className="text-dim uppercase">
<span>{count}</span>
<span className="hidden xs:inline-block">
&nbsp;
{photoLabelForCount(count)}
</span>
</div>
</div>}
breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
>
<TagForm {...{ tag, photos }}>
<PhotoLightbox

View File

@ -1,17 +1,6 @@
import FormWithConfirm from '@/components/FormWithConfirm';
import AdminTagTable from '@/admin/AdminTagTable';
import SiteGrid from '@/components/SiteGrid';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminGrid from '@/admin/AdminGrid';
import { Fragment } from 'react';
import DeleteButton from '@/admin/DeleteButton';
import { photoQuantityText } from '@/photo';
import { getUniqueTagsHiddenCached } from '@/photo/cache';
import PhotoTag from '@/tag/PhotoTag';
import { formatTag, isTagFavs, sortTagsObject } from '@/tag';
import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths';
import { clsx } from 'clsx/lite';
import FavsTag from '@/tag/FavsTag';
export default async function AdminTagsPage() {
const tags = await getUniqueTagsHiddenCached();
@ -21,34 +10,7 @@ export default async function AdminTagsPage() {
contentMain={
<div className="space-y-6">
<div className="space-y-4">
<AdminGrid>
{sortTagsObject(tags).map(({ tag, count }) =>
<Fragment key={tag}>
<div className="pr-2 -translate-y-0.5">
{isTagFavs(tag)
? <FavsTag prefetch={false} />
: <PhotoTag {...{ tag, prefetch: false }} />}
</div>
<div className="text-dim uppercase">
{photoQuantityText(count, false)}
</div>
<div className={clsx(
'flex flex-nowrap',
'gap-2 sm:gap-3 items-center',
)}>
<EditButton href={pathForAdminTagEdit(tag)} />
<FormWithConfirm
action={deletePhotoTagGloballyAction}
confirmText={
// eslint-disable-next-line max-len
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton />
</FormWithConfirm>
</div>
</Fragment>)}
</AdminGrid>
<AdminTagTable {...{ tags }} />
</div>
</div>}
/>

View File

@ -38,7 +38,6 @@ export default async function GridPage() {
<PhotoGrid {...{ photos, photoPriority: true }} />
{photos.length >= INFINITE_SCROLL_MULTIPLE_GRID &&
<InfinitePhotoScroll
swrKey={photos[0].id}
type='grid'
initialOffset={INFINITE_SCROLL_MULTIPLE_GRID}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_GRID}

View File

@ -33,7 +33,6 @@ export default async function HomePage() {
<PhotosLarge {...{ photos }} />
{photos.length >= INFINITE_SCROLL_MULTIPLE_HOME &&
<InfinitePhotoScroll
swrKey={photos[0].id}
type="full-frame"
initialOffset={INFINITE_SCROLL_MULTIPLE_HOME}
itemsPerPage={INFINITE_SCROLL_MULTIPLE_HOME}

View File

@ -35,12 +35,10 @@ export default function SubmitButtonWithStatus({
const pendingPrevious = useRef(pending);
useEffect(() => {
if (
pendingPrevious.current &&
!pending &&
onFormSubmitToastMessage
) {
toastSuccess(onFormSubmitToastMessage);
if (pending && !pendingPrevious.current) {
if (onFormSubmitToastMessage) {
toastSuccess(onFormSubmitToastMessage);
}
onFormSubmit?.();
}
pendingPrevious.current = pending;

View File

@ -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<any>;
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<HTMLDivElement>(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<Photo[]>(
(size: number, prev: []) => prev && prev.length === 0
? null
: [key, size],
keyGenerator,
fetcher,
{ revalidateFirstPage: false },
);
const buttonContainerRef = useRef<HTMLDivElement>(null);
const isLoadingOrValidating = isLoading || isValidating;
const isFinished = useMemo(() =>

View File

@ -72,7 +72,8 @@ export default function PhotoGrid({
priority: photoPriority,
}} />
</div>).concat(additionalTile ?? [])}
itemKeys={photos.map(photo => photo.id)}
itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])}
/>
);
};

View File

@ -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<string>();
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 (
<div className="space-y-8 max-w-[38rem] relative">
{debugBlur && blurError &&
@ -369,7 +369,14 @@ export default function PhotoForm({
<SubmitButtonWithStatus
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
onSubmit={clearSwr}
onFormSubmitToastMessage={type === 'edit'
? formData.title
? `"${formData.title}" updated`
: 'Photo updated'
: formData.title
? `"${formData.title}" created`
: 'Photo created'}
onFormSubmit={invalidateSwr}
primary
>
{type === 'create' ? 'Create' : 'Update'}

View File

@ -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={
<AnimateItems
animateOnFirstLoadOnly
type={animate ? 'bottom' : 'none'}
type={!isPathAdmin(pathname) ? 'bottom' : 'none'}
distanceOffset={10}
items={showNav
? [<div

View File

@ -4,6 +4,8 @@ import { AnimationConfig } from '@/components/AnimateItems';
export interface AppStateContext {
previousPathname?: string
hasLoaded?: boolean
swrTimestamp?: number
invalidateSwr?: () => void
userEmail?: string
setUserEmail?: Dispatch<SetStateAction<string | undefined>>
isUserSignedIn?: boolean

View File

@ -15,6 +15,8 @@ export default function AppStateProvider({
const [hasLoaded, setHasLoaded] =
useState(false);
const [swrTimestamp, setSwrTimestamp] =
useState(Date.now());
const [userEmail, setUserEmail] =
useState<string>();
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,

View File

@ -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({
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid}
onFormSubmit={invalidateSwr}
>
Update
</SubmitButtonWithStatus>