Post banner for recent changes

This commit is contained in:
Sam Becker 2024-04-27 21:15:15 -05:00
parent 89629f8494
commit 80661561ca
11 changed files with 143 additions and 54 deletions

View File

@ -1,6 +1,7 @@
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import { import {
getPhotosCountIncludingHiddenCached, getPhotosCountIncludingHiddenCached,
getPhotosMostRecentUpdateCached,
getUniqueTagsCached, getUniqueTagsCached,
} from '@/photo/cache'; } from '@/photo/cache';
import { import {
@ -15,6 +16,7 @@ export default async function AdminNav() {
countPhotos, countPhotos,
countUploads, countUploads,
countTags, countTags,
mostRecentUpdate,
] = await Promise.all([ ] = await Promise.all([
getPhotosCountIncludingHiddenCached().catch(() => 0), getPhotosCountIncludingHiddenCached().catch(() => 0),
getStorageUploadUrlsNoStore() getStorageUploadUrlsNoStore()
@ -24,6 +26,7 @@ export default async function AdminNav() {
return 0; return 0;
}), }),
getUniqueTagsCached().then(tags => tags.length).catch(() => 0), getUniqueTagsCached().then(tags => tags.length).catch(() => 0),
getPhotosMostRecentUpdateCached(),
]); ]);
const navItemPhotos = { const navItemPhotos = {
@ -44,12 +47,12 @@ export default async function AdminNav() {
count: countTags, count: countTags,
}; };
const navItems = [navItemPhotos]; const items = [navItemPhotos];
if (countUploads > 0) { navItems.push(navItemUploads); } if (countUploads > 0) { items.push(navItemUploads); }
if (countTags > 0) { navItems.push(navItemTags); } if (countTags > 0) { items.push(navItemTags); }
return ( return (
<AdminNavClient items={navItems} /> <AdminNavClient {...{ items, mostRecentUpdate }} />
); );
} }

View File

@ -1,65 +1,91 @@
'use client'; 'use client';
import InfoBlock from '@/components/InfoBlock';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { import {
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
checkPathPrefix, checkPathPrefix,
isPathAdminConfiguration, isPathAdminConfiguration,
} from '@/site/paths'; } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { differenceInMinutes } from 'date-fns';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { BiCog } from 'react-icons/bi'; import { BiCog } from 'react-icons/bi';
import { FaRegClock } from 'react-icons/fa';
const RECENCY_THRESHOLD = 5;
export default function AdminNavClient({ export default function AdminNavClient({
items, items,
mostRecentUpdate,
}: { }: {
items: { items: {
label: string, label: string,
href: string, href: string,
count: number, count: number,
}[] }[]
mostRecentUpdate?: Date
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const { adminUpdates = [] } = useAppState();
const shouldShowBanner = useMemo(() =>
((mostRecentUpdate ? [mostRecentUpdate] : []).concat(adminUpdates))
.some(date => differenceInMinutes(new Date(), date) < RECENCY_THRESHOLD)
, [mostRecentUpdate, adminUpdates]);
return ( return (
<SiteGrid <SiteGrid
contentMain={ contentMain={
<div className={clsx( <div className="space-y-5">
'flex gap-2 md:gap-4',
'border-b border-gray-200 dark:border-gray-800 pb-3',
)}>
<div className={clsx( <div className={clsx(
'flex gap-2 md:gap-4', 'flex gap-2 md:gap-4',
'flex-grow overflow-x-auto', 'border-b border-gray-200 dark:border-gray-800 pb-3',
)}> )}>
{items.map(({ label, href, count }) => <div className={clsx(
<Link 'flex gap-2 md:gap-4',
key={label} 'flex-grow overflow-x-auto',
href={href} )}>
className={clsx( {items.map(({ label, href, count }) =>
'flex gap-0.5', <Link
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim', key={label}
)} href={href}
prefetch={false} className={clsx(
> 'flex gap-0.5',
<span>{label}</span> checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
{count > 0 && )}
<span>({count})</span>} prefetch={false}
</Link>)} >
<span>{label}</span>
{count > 0 &&
<span>({count})</span>}
</Link>)}
</div>
<Link
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
>
<BiCog
size={18}
className="inline-block"
aria-label="App Configuration"
/>
</Link>
</div> </div>
<Link {shouldShowBanner &&
href={PATH_ADMIN_CONFIGURATION} <InfoBlock centered={false} padding="tight" color="blue">
className={isPathAdminConfiguration(pathname) <div className="flex items-center gap-3">
? 'font-bold' <FaRegClock className="flex-shrink-0" />
: 'text-dim'} Updates detectedthey may take several minutes to show up
> for visitors
<BiCog </div>
size={18} </InfoBlock>}
className="inline-block"
aria-label="App Configuration"
/>
</Link>
</div> </div>
} }
/> />

View File

@ -20,7 +20,7 @@ export default function AdminPhotoMenuClient({
photo: Photo photo: Photo
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
}) { }) {
const { isUserSignedIn } = useAppState(); const { isUserSignedIn, addAdminUpdate } = useAppState();
const isFav = isPhotoFav(photo); const isFav = isPhotoFav(photo);
const path = usePathname(); const path = usePathname();
@ -62,7 +62,10 @@ export default function AdminPhotoMenuClient({
photo.id, photo.id,
photo.url, photo.url,
shouldRedirectDelete, shouldRedirectDelete,
).then(() => revalidatePhoto?.(photo.id, true)); ).then(() => {
revalidatePhoto?.(photo.id, true);
addAdminUpdate?.();
});
} }
}, },
}, },

View File

@ -94,7 +94,7 @@ export default function AdminPhotoTable({
> >
<input type="hidden" name="id" value={photo.id} /> <input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} /> <input type="hidden" name="url" value={photo.url} />
<DeleteButton onFormSubmit={invalidateSwr} /> <DeleteButton clearLocalState />
</FormWithConfirm> </FormWithConfirm>
</div> </div>
</Fragment>)} </Fragment>)}

View File

@ -1,5 +1,3 @@
'use client';
import FormWithConfirm from '@/components/FormWithConfirm'; import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoTagGloballyAction } from '@/photo/actions'; import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable'; import AdminTable from '@/admin/AdminTable';
@ -11,15 +9,12 @@ import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/site/paths'; import { pathForAdminTagEdit } from '@/site/paths';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import AdminTagBadge from './AdminTagBadge'; import AdminTagBadge from './AdminTagBadge';
import { useAppState } from '@/state/AppState';
export default function AdminTagTable({ export default function AdminTagTable({
tags, tags,
}: { }: {
tags: TagsWithMeta tags: TagsWithMeta
}) { }) {
const { invalidateSwr } = useAppState();
return ( return (
<AdminTable> <AdminTable>
{sortTagsObject(tags).map(({ tag, count }) => {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()}?`} `Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
> >
<input type="hidden" name="tag" value={tag} /> <input type="hidden" name="tag" value={tag} />
<DeleteButton onFormSubmit={invalidateSwr} /> <DeleteButton clearLocalState />
</FormWithConfirm> </FormWithConfirm>
</div> </div>
</Fragment>)} </Fragment>)}

View File

@ -1,13 +1,34 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { ComponentProps } from 'react'; import { ComponentProps, useCallback } 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> props: ComponentProps<typeof SubmitButtonWithStatus> & {
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 <SubmitButtonWithStatus return <SubmitButtonWithStatus
{...props} {...rest}
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"
@ -17,5 +38,6 @@ export default function DeleteButton (
'!border-red-200 hover:!border-red-300', '!border-red-200 hover:!border-red-300',
'dark:!border-red-900/75 dark:hover:!border-red-900', 'dark:!border-red-900/75 dark:hover:!border-red-900',
)} )}
onFormSubmit={onFormSubmit}
/>; />;
} }

View File

@ -4,14 +4,32 @@ import { ReactNode } from 'react';
export default function InfoBlock({ export default function InfoBlock({
children, children,
className, className,
color = 'gray',
padding = 'normal', padding = 'normal',
centered = true, centered = true,
}: { }: {
children: ReactNode children: ReactNode
className?: string className?: string
color?: 'gray' | 'blue'
padding?: 'loose' | 'normal' | 'tight'; padding?: 'loose' | 'normal' | 'tight';
centered?: boolean; 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 = () => { const getPaddingClasses = () => {
switch (padding) { switch (padding) {
case 'loose': return 'p-4 md:p-24'; case 'loose': return 'p-4 md:p-24';
@ -24,8 +42,7 @@ export default function InfoBlock({
<div className={clsx( <div className={clsx(
'flex flex-col items-center justify-center', 'flex flex-col items-center justify-center',
'rounded-lg border', 'rounded-lg border',
'bg-gray-50 border-gray-200', ...getColorClasses(),
'dark:bg-gray-900/40 dark:border-gray-800',
getPaddingClasses(), getPaddingClasses(),
className, className,
)}> )}>
@ -33,7 +50,6 @@ export default function InfoBlock({
'flex flex-col justify-center w-full', 'flex flex-col justify-center w-full',
centered && 'items-center', centered && 'items-center',
'space-y-4', 'space-y-4',
'text-medium',
)}> )}>
{children} {children}
</div> </div>

View File

@ -19,6 +19,7 @@ import {
getPhotosFilmSimulationMeta, getPhotosFilmSimulationMeta,
getPhotosDateRange, getPhotosDateRange,
getPhotosNearId, getPhotosNearId,
getPhotosMostRecentUpdate,
} from '@/services/vercel-postgres'; } from '@/services/vercel-postgres';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera'; import { createCameraKey } from '@/camera';
@ -166,6 +167,12 @@ export const getPhotosCountIncludingHiddenCached =
[KEY_PHOTOS, KEY_COUNT, KEY_HIDDEN], [KEY_PHOTOS, KEY_COUNT, KEY_HIDDEN],
); );
export const getPhotosMostRecentUpdateCached =
unstable_cache(
() => getPhotosMostRecentUpdate(),
[KEY_PHOTOS, KEY_COUNT, KEY_DATE_RANGE],
);
export const getPhotosTagMetaCached = export const getPhotosTagMetaCached =
unstable_cache( unstable_cache(
getPhotosTagMeta, getPhotosTagMeta,

View File

@ -187,6 +187,10 @@ const sqlGetPhotosCountIncludingHidden = async () => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
`.then(({ rows }) => parseInt(rows[0].count, 10)); `.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` const sqlGetPhotosDateRange = async () => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos FROM photos
@ -481,6 +485,11 @@ export const getPhotosCountIncludingHidden = () =>
sqlGetPhotosCountIncludingHidden, sqlGetPhotosCountIncludingHidden,
'getPhotosCountIncludingHidden', 'getPhotosCountIncludingHidden',
); );
export const getPhotosMostRecentUpdate = () =>
safelyQueryPhotos(
sqlGetPhotosMostRecentUpdate,
'getPhotosMostRecentUpdate',
);
// TAGS // TAGS
export const getUniqueTags = () => export const getUniqueTags = () =>

View File

@ -16,6 +16,8 @@ export interface AppStateContext {
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>> setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
isCommandKOpen?: boolean isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>> setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
adminUpdates?: Date[]
addAdminUpdate?: () => void
shouldShowBaselineGrid?: boolean shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>> setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
clearNextPhotoAnimation?: () => void clearNextPhotoAnimation?: () => void

View File

@ -5,6 +5,7 @@ import { AppStateContext } from './AppState';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames'; import usePathnames from '@/utility/usePathnames';
import { getCurrentUser } from '@/auth/actions'; import { getCurrentUser } from '@/auth/actions';
import useSWR from 'swr';
export default function AppStateProvider({ export default function AppStateProvider({
children, children,
@ -25,19 +26,22 @@ export default function AppStateProvider({
useState(true); useState(true);
const [isCommandKOpen, setIsCommandKOpen] = const [isCommandKOpen, setIsCommandKOpen] =
useState(false); useState(false);
const [adminUpdates, setAdminUpdates] = useState<Date[]>([]);
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false); useState(false);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const captureUser = useCallback(() => const { data } = useSWR('getCurrentUser', getCurrentUser);
getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined)) useEffect(() => setUserEmail(data?.email ?? undefined), [data]);
const addAdminUpdate = useCallback(() =>
setAdminUpdates(updates => [...updates, new Date()])
, []); , []);
useEffect(() => { useEffect(() => {
setHasLoaded?.(true); setHasLoaded?.(true);
captureUser().catch(() => setTimeout(captureUser, 2000)); }, []);
}, [captureUser]);
return ( return (
<AppStateContext.Provider <AppStateContext.Provider
@ -56,6 +60,8 @@ export default function AppStateProvider({
setShouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands,
isCommandKOpen, isCommandKOpen,
setIsCommandKOpen, setIsCommandKOpen,
adminUpdates,
addAdminUpdate,
shouldShowBaselineGrid, shouldShowBaselineGrid,
setShouldShowBaselineGrid, setShouldShowBaselineGrid,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),