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