Introduce timestamp-based swr invalidation
This commit is contained in:
parent
11878f807c
commit
edb4df83b8
103
src/admin/AdminPhotoTable.tsx
Normal file
103
src/admin/AdminPhotoTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}: {
|
}: {
|
||||||
40
src/admin/AdminTagBadge.tsx
Normal file
40
src/admin/AdminTagBadge.tsx
Normal 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">
|
||||||
|
|
||||||
|
{photoLabelForCount(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
hideBadge
|
||||||
|
? renderBadgeContent()
|
||||||
|
: <Badge>{renderBadgeContent()}</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/admin/AdminTagTable.tsx
Normal file
48
src/admin/AdminTagTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
|
||||||
|
|
||||||
{photoLabelForCount(count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
>
|
>
|
||||||
<TagForm {...{ tag, photos }}>
|
<TagForm {...{ tag, photos }}>
|
||||||
<PhotoLightbox
|
<PhotoLightbox
|
||||||
|
|||||||
@ -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>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(() =>
|
||||||
|
|||||||
@ -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'] : [])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user