Post banner for recent changes
This commit is contained in:
parent
89629f8494
commit
80661561ca
@ -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 }} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 detected—they may take several minutes to show up
|
||||
for visitors
|
||||
</div>
|
||||
</InfoBlock>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@ -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>)}
|
||||
|
||||
@ -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>)}
|
||||
|
||||
@ -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}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = () =>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user