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 { clsx } from 'clsx/lite';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
export default function AdminGrid ({ export default function AdminTable ({
title, title,
children, 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 SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { ComponentProps } from 'react';
import { BiTrash } from 'react-icons/bi'; import { BiTrash } from 'react-icons/bi';
export default function DeleteButton () { export default function DeleteButton (
props: ComponentProps<typeof SubmitButtonWithStatus>
) {
return <SubmitButtonWithStatus return <SubmitButtonWithStatus
{...props}
title="Delete" title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />} icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
spinnerColor="text" spinnerColor="text"

View File

@ -1,5 +1,5 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import AdminGrid from './AdminGrid'; import AdminTable from './AdminTable';
import Link from 'next/link'; import Link from 'next/link';
import ImageTiny from '@/components/ImageTiny'; import ImageTiny from '@/components/ImageTiny';
import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage'; import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage';
@ -19,7 +19,7 @@ export default function StorageUrls({
urls: StorageListResponse urls: StorageListResponse
}) { }) {
return ( return (
<AdminGrid {...{ title }} > <AdminTable {...{ title }} >
{urls.map(({ url, uploadedAt }) => { {urls.map(({ url, uploadedAt }) => {
const addUploadPath = pathForAdminUploadUrl(url); const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForStorageUrl(url); const uploadFileName = fileNameForStorageUrl(url);
@ -70,6 +70,6 @@ export default function StorageUrls({
</FormWithConfirm> </FormWithConfirm>
</div> </div>
</Fragment>;})} </Fragment>;})}
</AdminGrid> </AdminTable>
); );
} }

View File

@ -1,39 +1,20 @@
import { Fragment } from 'react';
import PhotoUpload from '@/photo/PhotoUpload'; import PhotoUpload from '@/photo/PhotoUpload';
import Link from 'next/link';
import PhotoTiny from '@/photo/PhotoTiny';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import FormWithConfirm from '@/components/FormWithConfirm';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { import { pathForAdminPhotos } from '@/site/paths';
deletePhotoFormAction,
syncPhotoExifDataAction,
} from '@/photo/actions';
import {
pathForAdminPhotos,
pathForPhoto,
pathForAdminPhotoEdit,
} from '@/site/paths';
import { deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import { getPhotosCountIncludingHiddenCached } from '@/photo/cache'; import { getPhotosCountIncludingHiddenCached } from '@/photo/cache';
import { AiOutlineEyeInvisible } from 'react-icons/ai';
import { import {
PaginationParams, PaginationParams,
getPaginationFromSearchParams, getPaginationFromSearchParams,
} from '@/site/pagination'; } from '@/site/pagination';
import AdminGrid from '@/admin/AdminGrid';
import DeleteButton from '@/admin/DeleteButton';
import EditButton from '@/admin/EditButton';
import StorageUrls from '@/admin/StorageUrls'; import StorageUrls from '@/admin/StorageUrls';
import { PRO_MODE_ENABLED } from '@/site/config'; import { PRO_MODE_ENABLED } from '@/site/config';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import IconGrSync from '@/site/IconGrSync';
import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache'; import { getStoragePhotoUrlsNoStore } from '@/services/storage/cache';
import MoreComponentsFromSearchParams from import MoreComponentsFromSearchParams from
'@/components/MoreComponentsFromSearchParams'; '@/components/MoreComponentsFromSearchParams';
import { getPhotos } from '@/services/vercel-postgres'; import { getPhotos } from '@/services/vercel-postgres';
import PhotoDate from '@/photo/PhotoDate';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import AdminPhotoTable from '@/admin/AdminPhotoTable';
const DEBUG_PHOTO_BLOBS = false; const DEBUG_PHOTO_BLOBS = false;
@ -77,76 +58,7 @@ export default async function AdminPhotosPage({
/> />
</div>} </div>}
<div className="space-y-4"> <div className="space-y-4">
<AdminGrid> <AdminPhotoTable photos={photos} />
{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>
{showMorePhotos && {showMorePhotos &&
<MoreComponentsFromSearchParams <MoreComponentsFromSearchParams
label="More photos" label="More photos"

View File

@ -3,13 +3,9 @@ import { redirect } from 'next/navigation';
import { getPhotosCached } from '@/photo/cache'; import { getPhotosCached } from '@/photo/cache';
import TagForm from '@/tag/TagForm'; import TagForm from '@/tag/TagForm';
import { PATH_ADMIN, PATH_ADMIN_TAGS, pathForTag } from '@/site/paths'; 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 PhotoLightbox from '@/photo/PhotoLightbox';
import FavsTag from '@/tag/FavsTag';
import { isTagFavs } from '@/tag';
import { getPhotosTagMeta } from '@/services/vercel-postgres'; import { getPhotosTagMeta } from '@/services/vercel-postgres';
import { clsx } from 'clsx/lite'; import AdminTagBadge from '@/admin/AdminTagBadge';
const MAX_PHOTO_TO_SHOW = 6; const MAX_PHOTO_TO_SHOW = 6;
@ -36,22 +32,7 @@ export default async function PhotoPageEdit({
<AdminChildPage <AdminChildPage
backPath={PATH_ADMIN_TAGS} backPath={PATH_ADMIN_TAGS}
backLabel="Tags" backLabel="Tags"
breadcrumb={<div className={clsx( breadcrumb={<AdminTagBadge {...{ tag, count, hideBadge: true }} />}
'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>}
> >
<TagForm {...{ tag, photos }}> <TagForm {...{ tag, photos }}>
<PhotoLightbox <PhotoLightbox

View File

@ -1,17 +1,6 @@
import FormWithConfirm from '@/components/FormWithConfirm'; import AdminTagTable from '@/admin/AdminTagTable';
import SiteGrid from '@/components/SiteGrid'; 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 { 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() { export default async function AdminTagsPage() {
const tags = await getUniqueTagsHiddenCached(); const tags = await getUniqueTagsHiddenCached();
@ -21,34 +10,7 @@ export default async function AdminTagsPage() {
contentMain={ contentMain={
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<AdminGrid> <AdminTagTable {...{ tags }} />
{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>
</div> </div>
</div>} </div>}
/> />

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import { getPhotosAction } from '@/photo/actions';
import { Photo } from '.'; import { Photo } from '.';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
export type RevalidatePhoto = ( export type RevalidatePhoto = (
photoId: string, photoId: string,
@ -17,14 +18,12 @@ export type RevalidatePhoto = (
) => Promise<any>; ) => Promise<any>;
export default function InfinitePhotoScroll({ export default function InfinitePhotoScroll({
swrKey,
type = 'full-frame', type = 'full-frame',
initialOffset = 0, initialOffset = 0,
itemsPerPage = 12, itemsPerPage = 12,
prefetch = true, prefetch = true,
triggerOnView = true, triggerOnView = true,
}: { }: {
swrKey: string
type?: 'full-frame' | 'grid' type?: 'full-frame' | 'grid'
initialOffset?: number initialOffset?: number
itemsPerPage?: number itemsPerPage?: number
@ -32,9 +31,15 @@ export default function InfinitePhotoScroll({
triggerOnView?: boolean triggerOnView?: boolean
debug?: 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]) => const fetcher = useCallback(([_key, size]: [string, number]) =>
getPhotosAction( getPhotosAction(
@ -45,13 +50,13 @@ export default function InfinitePhotoScroll({
const { data, isLoading, isValidating, error, mutate, size, setSize } = const { data, isLoading, isValidating, error, mutate, size, setSize } =
useSwrInfinite<Photo[]>( useSwrInfinite<Photo[]>(
(size: number, prev: []) => prev && prev.length === 0 keyGenerator,
? null
: [key, size],
fetcher, fetcher,
{ revalidateFirstPage: false }, { revalidateFirstPage: false },
); );
const buttonContainerRef = useRef<HTMLDivElement>(null);
const isLoadingOrValidating = isLoading || isValidating; const isLoadingOrValidating = isLoading || isValidating;
const isFinished = useMemo(() => const isFinished = useMemo(() =>

View File

@ -72,7 +72,8 @@ export default function PhotoGrid({
priority: photoPriority, priority: photoPriority,
}} /> }} />
</div>).concat(additionalTile ?? [])} </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 { getNextImageUrlForRequest } from '@/services/next-image';
import useDelay from '@/utility/useDelay'; import useDelay from '@/utility/useDelay';
import usePreventNavigation from '@/utility/usePreventNavigation'; import usePreventNavigation from '@/utility/usePreventNavigation';
import useSwrClear from '@/state/useSwrClear'; import { useAppState } from '@/state/AppState';
const THUMBNAIL_SIZE = 300; const THUMBNAIL_SIZE = 300;
@ -63,6 +63,8 @@ export default function PhotoForm({
useState<string>(); useState<string>();
const [hasBlurData, setHasBlurData] = useState(false); const [hasBlurData, setHasBlurData] = useState(false);
const { invalidateSwr } = useAppState();
const changedFormKeys = useMemo(() => const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData), getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]); [initialPhotoForm, formData]);
@ -215,8 +217,6 @@ export default function PhotoForm({
} }
}; };
const clearSwr = useSwrClear();
return ( return (
<div className="space-y-8 max-w-[38rem] relative"> <div className="space-y-8 max-w-[38rem] relative">
{debugBlur && blurError && {debugBlur && blurError &&
@ -369,7 +369,14 @@ export default function PhotoForm({
<SubmitButtonWithStatus <SubmitButtonWithStatus
disabled={!canFormBeSubmitted} disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange} onFormStatusChange={onFormStatusChange}
onSubmit={clearSwr} onFormSubmitToastMessage={type === 'edit'
? formData.title
? `"${formData.title}" updated`
: 'Photo updated'
: formData.title
? `"${formData.title}" created`
: 'Photo created'}
onFormSubmit={invalidateSwr}
primary primary
> >
{type === 'create' ? 'Create' : 'Update'} {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 ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import { import {
PATH_ROOT, PATH_ROOT,
isPathAdmin,
isPathGrid, isPathGrid,
isPathProtected, isPathProtected,
isPathSignIn, isPathSignIn,
@ -15,11 +16,7 @@ import {
import AnimateItems from '../components/AnimateItems'; import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
export default function Nav({ export default function Nav() {
animate = true,
}: {
animate?: boolean,
}) {
const pathname = usePathname(); const pathname = usePathname();
const { isUserSignedIn } = useAppState(); const { isUserSignedIn } = useAppState();
@ -49,7 +46,7 @@ export default function Nav({
contentMain={ contentMain={
<AnimateItems <AnimateItems
animateOnFirstLoadOnly animateOnFirstLoadOnly
type={animate ? 'bottom' : 'none'} type={!isPathAdmin(pathname) ? 'bottom' : 'none'}
distanceOffset={10} distanceOffset={10}
items={showNav items={showNav
? [<div ? [<div

View File

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

View File

@ -15,6 +15,8 @@ export default function AppStateProvider({
const [hasLoaded, setHasLoaded] = const [hasLoaded, setHasLoaded] =
useState(false); useState(false);
const [swrTimestamp, setSwrTimestamp] =
useState(Date.now());
const [userEmail, setUserEmail] = const [userEmail, setUserEmail] =
useState<string>(); useState<string>();
const [nextPhotoAnimation, setNextPhotoAnimation] = const [nextPhotoAnimation, setNextPhotoAnimation] =
@ -26,6 +28,8 @@ export default function AppStateProvider({
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false); useState(false);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const captureUser = useCallback(() => const captureUser = useCallback(() =>
getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined)) getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined))
, []); , []);
@ -40,6 +44,8 @@ export default function AppStateProvider({
value={{ value={{
previousPathname, previousPathname,
hasLoaded, hasLoaded,
swrTimestamp,
invalidateSwr,
setHasLoaded, setHasLoaded,
isUserSignedIn: userEmail !== undefined, isUserSignedIn: userEmail !== undefined,
userEmail, userEmail,

View File

@ -7,6 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ReactNode, useMemo, useState } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoTagGloballyAction } from '@/photo/actions'; import { renamePhotoTagGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState';
export default function TagForm({ export default function TagForm({
tag, tag,
@ -15,6 +16,8 @@ export default function TagForm({
tag: string tag: string
children?: ReactNode children?: ReactNode
}) { }) {
const { invalidateSwr } = useAppState();
const [updatedTagRaw, setUpdatedTagRaw] = useState(tag); const [updatedTagRaw, setUpdatedTagRaw] = useState(tag);
const updatedTag = useMemo(() => const updatedTag = useMemo(() =>
@ -61,6 +64,7 @@ export default function TagForm({
</Link> </Link>
<SubmitButtonWithStatus <SubmitButtonWithStatus
disabled={!isFormValid} disabled={!isFormValid}
onFormSubmit={invalidateSwr}
> >
Update Update
</SubmitButtonWithStatus> </SubmitButtonWithStatus>