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 {
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 (
<AdminNavClient items={navItems} />
<AdminNavClient {...{ items, mostRecentUpdate }} />
);
}

View File

@ -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 (
<SiteGrid
contentMain={
<div className={clsx(
'flex gap-2 md:gap-4',
'border-b border-gray-200 dark:border-gray-800 pb-3',
)}>
<div className="space-y-5">
<div className={clsx(
'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 }) =>
<Link
key={label}
href={href}
className={clsx(
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
)}
prefetch={false}
>
<span>{label}</span>
{count > 0 &&
<span>({count})</span>}
</Link>)}
<div className={clsx(
'flex gap-2 md:gap-4',
'flex-grow overflow-x-auto',
)}>
{items.map(({ label, href, count }) =>
<Link
key={label}
href={href}
className={clsx(
'flex gap-0.5',
checkPathPrefix(pathname, href) ? 'font-bold' : 'text-dim',
)}
prefetch={false}
>
<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>
<Link
href={PATH_ADMIN_CONFIGURATION}
className={isPathAdminConfiguration(pathname)
? 'font-bold'
: 'text-dim'}
>
<BiCog
size={18}
className="inline-block"
aria-label="App Configuration"
/>
</Link>
{shouldShowBanner &&
<InfoBlock centered={false} padding="tight" color="blue">
<div className="flex items-center gap-3">
<FaRegClock className="flex-shrink-0" />
Updates detectedthey may take several minutes to show up
for visitors
</div>
</InfoBlock>}
</div>
}
/>

View File

@ -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?.();
});
}
},
},

View File

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

View File

@ -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 (
<AdminTable>
{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()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton onFormSubmit={invalidateSwr} />
<DeleteButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}

View File

@ -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<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
{...props}
{...rest}
title="Delete"
icon={<BiTrash size={16} className="translate-y-[-1.5px]" />}
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}
/>;
}

View File

@ -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({
<div className={clsx(
'flex flex-col items-center justify-center',
'rounded-lg border',
'bg-gray-50 border-gray-200',
'dark:bg-gray-900/40 dark:border-gray-800',
...getColorClasses(),
getPaddingClasses(),
className,
)}>
@ -33,7 +50,6 @@ export default function InfoBlock({
'flex flex-col justify-center w-full',
centered && 'items-center',
'space-y-4',
'text-medium',
)}>
{children}
</div>

View File

@ -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,

View File

@ -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 = () =>

View File

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

View File

@ -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<Date[]>([]);
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 (
<AppStateContext.Provider
@ -56,6 +60,8 @@ export default function AppStateProvider({
setShouldRespondToKeyboardCommands,
isCommandKOpen,
setIsCommandKOpen,
adminUpdates,
addAdminUpdate,
shouldShowBaselineGrid,
setShouldShowBaselineGrid,
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),