From 80661561caae892a7abbd1cd852d4269daaeb85a Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 27 Apr 2024 21:15:15 -0500 Subject: [PATCH] Post banner for recent changes --- src/admin/AdminNav.tsx | 11 ++-- src/admin/AdminNavClient.tsx | 88 +++++++++++++++++++----------- src/admin/AdminPhotoMenuClient.tsx | 7 ++- src/admin/AdminPhotoTable.tsx | 2 +- src/admin/AdminTagTable.tsx | 7 +-- src/admin/DeleteButton.tsx | 28 +++++++++- src/components/InfoBlock.tsx | 22 +++++++- src/photo/cache.ts | 7 +++ src/services/vercel-postgres.ts | 9 +++ src/state/AppState.ts | 2 + src/state/AppStateProvider.tsx | 14 +++-- 11 files changed, 143 insertions(+), 54 deletions(-) diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 95a9d24a..ecc00562 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -1,6 +1,7 @@ import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { getPhotosCountIncludingHiddenCached, + getPhotosMostRecentUpdateCached, getUniqueTagsCached, } from '@/photo/cache'; import { @@ -15,6 +16,7 @@ export default async function AdminNav() { countPhotos, countUploads, countTags, + mostRecentUpdate, ] = await Promise.all([ getPhotosCountIncludingHiddenCached().catch(() => 0), getStorageUploadUrlsNoStore() @@ -24,6 +26,7 @@ export default async function AdminNav() { return 0; }), getUniqueTagsCached().then(tags => tags.length).catch(() => 0), + getPhotosMostRecentUpdateCached(), ]); const navItemPhotos = { @@ -44,12 +47,12 @@ export default async function AdminNav() { count: countTags, }; - const navItems = [navItemPhotos]; + const items = [navItemPhotos]; - if (countUploads > 0) { navItems.push(navItemUploads); } - if (countTags > 0) { navItems.push(navItemTags); } + if (countUploads > 0) { items.push(navItemUploads); } + if (countTags > 0) { items.push(navItemTags); } return ( - + ); } diff --git a/src/admin/AdminNavClient.tsx b/src/admin/AdminNavClient.tsx index 85b44936..58348a5e 100644 --- a/src/admin/AdminNavClient.tsx +++ b/src/admin/AdminNavClient.tsx @@ -1,65 +1,91 @@ 'use client'; +import InfoBlock from '@/components/InfoBlock'; import SiteGrid from '@/components/SiteGrid'; import { PATH_ADMIN_CONFIGURATION, checkPathPrefix, isPathAdminConfiguration, } from '@/site/paths'; +import { useAppState } from '@/state/AppState'; import { clsx } from 'clsx/lite'; +import { differenceInMinutes } from 'date-fns'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useMemo } from 'react'; import { BiCog } from 'react-icons/bi'; +import { FaRegClock } from 'react-icons/fa'; + +const RECENCY_THRESHOLD = 5; export default function AdminNavClient({ items, + mostRecentUpdate, }: { items: { label: string, href: string, count: number, }[] + mostRecentUpdate?: Date }) { const pathname = usePathname(); + const { adminUpdates = [] } = useAppState(); + + const shouldShowBanner = useMemo(() => + ((mostRecentUpdate ? [mostRecentUpdate] : []).concat(adminUpdates)) + .some(date => differenceInMinutes(new Date(), date) < RECENCY_THRESHOLD) + , [mostRecentUpdate, adminUpdates]); + return ( +
- {items.map(({ label, href, count }) => - - {label} - {count > 0 && - ({count})} - )} +
+ {items.map(({ label, href, count }) => + + {label} + {count > 0 && + ({count})} + )} +
+ + +
- - - + {shouldShowBanner && + +
+ + Updates detected—they may take several minutes to show up + for visitors +
+
}
} /> diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index 10ccceec..a6fdbf0e 100644 --- a/src/admin/AdminPhotoMenuClient.tsx +++ b/src/admin/AdminPhotoMenuClient.tsx @@ -20,7 +20,7 @@ export default function AdminPhotoMenuClient({ photo: Photo revalidatePhoto?: RevalidatePhoto }) { - const { isUserSignedIn } = useAppState(); + const { isUserSignedIn, addAdminUpdate } = useAppState(); const isFav = isPhotoFav(photo); const path = usePathname(); @@ -62,7 +62,10 @@ export default function AdminPhotoMenuClient({ photo.id, photo.url, shouldRedirectDelete, - ).then(() => revalidatePhoto?.(photo.id, true)); + ).then(() => { + revalidatePhoto?.(photo.id, true); + addAdminUpdate?.(); + }); } }, }, diff --git a/src/admin/AdminPhotoTable.tsx b/src/admin/AdminPhotoTable.tsx index e5527ce0..4bdb4d2e 100644 --- a/src/admin/AdminPhotoTable.tsx +++ b/src/admin/AdminPhotoTable.tsx @@ -94,7 +94,7 @@ export default function AdminPhotoTable({ > - + )} diff --git a/src/admin/AdminTagTable.tsx b/src/admin/AdminTagTable.tsx index d772c00e..a8eadf4a 100644 --- a/src/admin/AdminTagTable.tsx +++ b/src/admin/AdminTagTable.tsx @@ -1,5 +1,3 @@ -'use client'; - import FormWithConfirm from '@/components/FormWithConfirm'; import { deletePhotoTagGloballyAction } from '@/photo/actions'; import AdminTable from '@/admin/AdminTable'; @@ -11,15 +9,12 @@ 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 }) => @@ -39,7 +34,7 @@ export default function AdminTagTable({ `Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`} > - + )} diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 554abb4c..7128c24f 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -1,13 +1,34 @@ +'use client'; + import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import { useAppState } from '@/state/AppState'; import { clsx } from 'clsx/lite'; -import { ComponentProps } from 'react'; +import { ComponentProps, useCallback } from 'react'; import { BiTrash } from 'react-icons/bi'; export default function DeleteButton ( - props: ComponentProps + props: ComponentProps & { + clearLocalState?: boolean + } ) { + const { + onFormSubmit: onFormSubmitProps, + clearLocalState, + ...rest + } = props; + + const { invalidateSwr, addAdminUpdate } = useAppState(); + + const onFormSubmit = useCallback(() => { + onFormSubmitProps?.(); + if (clearLocalState) { + invalidateSwr?.(); + addAdminUpdate?.(); + } + }, [onFormSubmitProps, clearLocalState, invalidateSwr, addAdminUpdate]); + return } spinnerColor="text" @@ -17,5 +38,6 @@ export default function DeleteButton ( '!border-red-200 hover:!border-red-300', 'dark:!border-red-900/75 dark:hover:!border-red-900', )} + onFormSubmit={onFormSubmit} />; } diff --git a/src/components/InfoBlock.tsx b/src/components/InfoBlock.tsx index d1ea4021..6f17a363 100644 --- a/src/components/InfoBlock.tsx +++ b/src/components/InfoBlock.tsx @@ -4,14 +4,32 @@ import { ReactNode } from 'react'; export default function InfoBlock({ children, className, + color = 'gray', padding = 'normal', centered = true, }: { children: ReactNode className?: string + color?: 'gray' | 'blue' padding?: 'loose' | 'normal' | 'tight'; centered?: boolean; } ) { + const getColorClasses = () => { + switch (color) { + case 'gray': return [ + 'text-medium', + 'bg-gray-50 border-gray-200', + 'dark:bg-gray-900/40 dark:border-gray-800', + ]; + case 'blue': return [ + 'text-gray-700/70', + 'dark:text-gray-300/75', + 'bg-blue-50 border-blue-200', + 'dark:bg-blue-900/25 dark:border-blue-800/35', + ]; + } + }; + const getPaddingClasses = () => { switch (padding) { case 'loose': return 'p-4 md:p-24'; @@ -24,8 +42,7 @@ export default function InfoBlock({
@@ -33,7 +50,6 @@ export default function InfoBlock({ 'flex flex-col justify-center w-full', centered && 'items-center', 'space-y-4', - 'text-medium', )}> {children}
diff --git a/src/photo/cache.ts b/src/photo/cache.ts index f7be25b0..93f63d8a 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -19,6 +19,7 @@ import { getPhotosFilmSimulationMeta, getPhotosDateRange, getPhotosNearId, + getPhotosMostRecentUpdate, } from '@/services/vercel-postgres'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { createCameraKey } from '@/camera'; @@ -166,6 +167,12 @@ export const getPhotosCountIncludingHiddenCached = [KEY_PHOTOS, KEY_COUNT, KEY_HIDDEN], ); +export const getPhotosMostRecentUpdateCached = + unstable_cache( + () => getPhotosMostRecentUpdate(), + [KEY_PHOTOS, KEY_COUNT, KEY_DATE_RANGE], + ); + export const getPhotosTagMetaCached = unstable_cache( getPhotosTagMeta, diff --git a/src/services/vercel-postgres.ts b/src/services/vercel-postgres.ts index 09bfc701..fa4d1778 100644 --- a/src/services/vercel-postgres.ts +++ b/src/services/vercel-postgres.ts @@ -187,6 +187,10 @@ const sqlGetPhotosCountIncludingHidden = async () => sql` SELECT COUNT(*) FROM photos `.then(({ rows }) => parseInt(rows[0].count, 10)); +const sqlGetPhotosMostRecentUpdate = async () => sql` + SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1 +`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined); + const sqlGetPhotosDateRange = async () => sql` SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos @@ -481,6 +485,11 @@ export const getPhotosCountIncludingHidden = () => sqlGetPhotosCountIncludingHidden, 'getPhotosCountIncludingHidden', ); +export const getPhotosMostRecentUpdate = () => + safelyQueryPhotos( + sqlGetPhotosMostRecentUpdate, + 'getPhotosMostRecentUpdate', + ); // TAGS export const getUniqueTags = () => diff --git a/src/state/AppState.ts b/src/state/AppState.ts index d505f4d7..7325b540 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -16,6 +16,8 @@ export interface AppStateContext { setShouldRespondToKeyboardCommands?: Dispatch> isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> + adminUpdates?: Date[] + addAdminUpdate?: () => void shouldShowBaselineGrid?: boolean setShouldShowBaselineGrid?: Dispatch> clearNextPhotoAnimation?: () => void diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index a186e686..becd004b 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -5,6 +5,7 @@ import { AppStateContext } from './AppState'; import { AnimationConfig } from '@/components/AnimateItems'; import usePathnames from '@/utility/usePathnames'; import { getCurrentUser } from '@/auth/actions'; +import useSWR from 'swr'; export default function AppStateProvider({ children, @@ -25,19 +26,22 @@ export default function AppStateProvider({ useState(true); const [isCommandKOpen, setIsCommandKOpen] = useState(false); + const [adminUpdates, setAdminUpdates] = useState([]); const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); - const captureUser = useCallback(() => - getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined)) + const { data } = useSWR('getCurrentUser', getCurrentUser); + useEffect(() => setUserEmail(data?.email ?? undefined), [data]); + + const addAdminUpdate = useCallback(() => + setAdminUpdates(updates => [...updates, new Date()]) , []); useEffect(() => { setHasLoaded?.(true); - captureUser().catch(() => setTimeout(captureUser, 2000)); - }, [captureUser]); + }, []); return ( setNextPhotoAnimation?.(undefined),