diff --git a/src/admin/AdminAppInfoIcon.tsx b/src/admin/AdminAppInfoIcon.tsx index 8cd3c4b3..dac461da 100644 --- a/src/admin/AdminAppInfoIcon.tsx +++ b/src/admin/AdminAppInfoIcon.tsx @@ -1,7 +1,7 @@ import { useAppState } from '@/state/AppState'; import clsx from 'clsx/lite'; -import { FaCircle } from 'react-icons/fa6'; import { LuCog } from 'react-icons/lu'; +import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; export default function AdminAppInfoIcon({ size = 'large', @@ -18,22 +18,15 @@ export default function AdminAppInfoIcon({ className, )}> {insightIndicatorStatus && - } ); diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 499242b5..a0b62c91 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -5,6 +5,7 @@ import { PATH_ADMIN_INSIGHTS, PATH_ADMIN_PHOTOS, PATH_ADMIN_TAGS, + PATH_ADMIN_UPLOADS, PATH_GRID_INFERRED, } from '@/app/paths'; import { useAppState } from '@/state/AppState'; @@ -17,6 +18,8 @@ import { BiLockAlt } from 'react-icons/bi'; import AdminAppInfoIcon from './AdminAppInfoIcon'; import { PiSignOutBold } from 'react-icons/pi'; import { signOutAndRedirectAction } from '@/auth/actions'; +import { ComponentProps } from 'react'; +import { FaRegFolderOpen } from 'react-icons/fa'; export default function AdminAppMenu({ className, @@ -26,12 +29,87 @@ export default function AdminAppMenu({ buttonClassName?: string }) { const { + photosCount, + uploadsCount, + tagsCount, selectedPhotoIds, setSelectedPhotoIds, } = useAppState(); const isSelecting = selectedPhotoIds !== undefined; + const items: ComponentProps['items'] = [{ + label: 'Manage Photos', + ...photosCount !== undefined && { + annotation: `${photosCount}`, + }, + icon: , + href: PATH_ADMIN_PHOTOS, + }]; + + if (uploadsCount) { + items.push({ + label: 'Uploads', + annotation: `${uploadsCount}`, + icon: , + href: PATH_ADMIN_UPLOADS, + }); + } + + if (tagsCount) { + items.push({ + label: 'Manage Tags', + annotation: `${tagsCount}`, + icon: , + href: PATH_ADMIN_TAGS, + }); + } + + items.push({ + label: 'App Info', + icon: , + href: PATH_ADMIN_INSIGHTS, + }, { + label: isSelecting + ? 'Exit Select' + : 'Edit Multiple …', + icon: isSelecting + ? + : , + href: PATH_GRID_INFERRED, + action: () => { + if (isSelecting) { + setSelectedPhotoIds?.(undefined); + } else { + setSelectedPhotoIds?.([]); + } + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }, + shouldPreventDefault: false, + }, { + label: 'Sign Out', + icon: , + action: signOutAndRedirectAction, + }); + return ( , - href: PATH_ADMIN_PHOTOS, - }, { - label: 'Manage Tags', - icon: , - href: PATH_ADMIN_TAGS, - }, { - label: 'App Info', - icon: , - href: PATH_ADMIN_INSIGHTS, - }, { - label: isSelecting - ? 'Exit Select' - : 'Select Photos', - icon: isSelecting - ? - : , - href: PATH_GRID_INFERRED, - action: () => { - if (isSelecting) { - setSelectedPhotoIds?.(undefined); - } else { - setSelectedPhotoIds?.([]); - } - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - }, - shouldPreventDefault: false, - }, { - label: 'Sign Out', - icon: , - action: signOutAndRedirectAction, - }]} + items={items} ariaLabel="Admin Menu" /> ); diff --git a/src/admin/AdminInfoNav.tsx b/src/admin/AdminInfoNav.tsx index 3495440a..70e9cd81 100644 --- a/src/admin/AdminInfoNav.tsx +++ b/src/admin/AdminInfoNav.tsx @@ -7,7 +7,7 @@ import clsx from 'clsx/lite'; import ClearCacheButton from '@/admin/ClearCacheButton'; import { usePathname } from 'next/navigation'; import { useAppState } from '@/state/AppState'; -import { FaCircle } from 'react-icons/fa6'; +import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; const ADMIN_INFO_PAGES = [{ title: 'Insights', @@ -65,14 +65,10 @@ export default function AdminInfoPage({ {title} {title === 'Insights' && insightIndicatorStatus && - } )} diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index e6f10e30..eecda0aa 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -14,20 +14,21 @@ import AdminNavClient from './AdminNavClient'; export default async function AdminNav() { const [ countPhotos, - countUploads, countTags, + countUploads, mostRecentPhotoUpdateTime, ] = await Promise.all([ getPhotosMetaCached({ hidden: 'include' }) .then(({ count }) => count) .catch(() => 0), + getUniqueTagsCached().then(tags => tags.length) + .catch(() => 0), getStorageUploadUrlsNoStore() .then(urls => urls.length) .catch(e => { console.error(`Error getting blob upload urls: ${e}`); return 0; }), - getUniqueTagsCached().then(tags => tags.length).catch(() => 0), getPhotosMostRecentUpdateCached().catch(() => undefined), ]); diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index 0cd75923..9e7dd48b 100644 --- a/src/admin/AdminPhotoMenuClient.tsx +++ b/src/admin/AdminPhotoMenuClient.tsx @@ -23,7 +23,7 @@ import { MdOutlineFileDownload } from 'react-icons/md'; import MoreMenuItem from '@/components/more/MoreMenuItem'; import IconGrSync from '@/app/IconGrSync'; import { isPhotoOutdated } from '@/photo/outdated'; -import { FaCircle } from 'react-icons/fa6'; +import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; export default function AdminPhotoMenuClient({ photo, @@ -81,12 +81,13 @@ export default function AdminPhotoMenuClient({ hrefDownloadName: downloadFileNameForPhoto(photo), }); items.push({ - label: + label: 'Sync', + labelComplex: Sync {isPhotoOutdated(photo) && - } , icon: , diff --git a/src/admin/actions.ts b/src/admin/actions.ts index de215f9c..a79b27cf 100644 --- a/src/admin/actions.ts +++ b/src/admin/actions.ts @@ -6,6 +6,45 @@ import { testOpenAiConnection } from '@/platforms/openai'; import { testDatabaseConnection } from '@/platforms/postgres'; import { testStorageConnection } from '@/platforms/storage'; import { APP_CONFIGURATION } from '@/app/config'; +import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; +import { getPhotosMetaCached, getUniqueTagsCached } from '@/photo/cache'; +import { getShouldShowInsightsIndicator } from '@/admin/insights/server'; + +export const getAdminDataAction = async () => + runAuthenticatedAdminServerAction(async () => { + const [ + countPhotos, + countHiddenPhotos, + countTags, + countUploads, + shouldShowInsightsIndicator, + ] = await Promise.all([ + getPhotosMetaCached() + .then(({ count }) => count) + .catch(() => 0), + getPhotosMetaCached({ hidden: 'only' }) + .then(({ count }) => count) + .catch(() => 0), + getUniqueTagsCached() + .then(tags => tags.length) + .catch(() => 0), + getStorageUploadUrlsNoStore() + .then(urls => urls.length) + .catch(e => { + console.error(`Error getting blob upload urls: ${e}`); + return 0; + }), + getShouldShowInsightsIndicator(), + ]); + + return { + countPhotos, + countHiddenPhotos, + countTags, + countUploads, + shouldShowInsightsIndicator, + }; + }); const scanForError = ( shouldCheck: boolean, diff --git a/src/admin/insights/InsightsIndicatorDot.tsx b/src/admin/insights/InsightsIndicatorDot.tsx new file mode 100644 index 00000000..4d8d6377 --- /dev/null +++ b/src/admin/insights/InsightsIndicatorDot.tsx @@ -0,0 +1,50 @@ +import { useAppState } from '@/state/AppState'; +import clsx from 'clsx/lite'; +import { FaCircle } from 'react-icons/fa6'; + +export default function InsightsIndicatorDot({ + className, + size = 'medium', + colorOverride, + top, + right, + bottom, + left, +}: { + className?: string + size?: 'small' | 'medium' | 'large' + colorOverride?: 'blue' | 'yellow' + top?: number + right?: number + bottom?: number + left?: number +}) { + const { insightIndicatorStatus } = useAppState(); + + const getSize = () => { + switch (size) { + case 'small': return 6; + case 'medium': return 7; + case 'large': return 8; + } + }; + + return ( + + ); +} diff --git a/src/admin/insights/actions.ts b/src/admin/insights/actions.ts deleted file mode 100644 index ed6c03f9..00000000 --- a/src/admin/insights/actions.ts +++ /dev/null @@ -1,36 +0,0 @@ -'use server'; - -import { runAuthenticatedAdminServerAction } from '@/auth'; -import { - getGitHubMetaForCurrentApp, - getSignificantInsights, - InsightIndicatorStatus, -} from '.'; -import { getOutdatedPhotosCount } from '@/photo/db/query'; - -export const getShouldShowInsightsIndicatorAction = - async (): Promise => - runAuthenticatedAdminServerAction(async () => { - const [ - codeMeta, - photosCountOutdated, - ] = await Promise.all([ - getGitHubMetaForCurrentApp(), - getOutdatedPhotosCount(), - ]); - - const { - forkBehind, - noAiRateLimiting, - outdatedPhotos, - } = getSignificantInsights({ - codeMeta, - photosCountOutdated, - }); - - if (noAiRateLimiting || outdatedPhotos) { - return 'yellow'; - } else if (forkBehind) { - return 'blue'; - } - }); diff --git a/src/admin/insights/server.ts b/src/admin/insights/server.ts new file mode 100644 index 00000000..b721d3b6 --- /dev/null +++ b/src/admin/insights/server.ts @@ -0,0 +1,28 @@ +import { getOutdatedPhotosCount } from '@/photo/db/query'; +import { getSignificantInsights } from '.'; +import { getGitHubMetaForCurrentApp } from '.'; + +export const getShouldShowInsightsIndicator = async () => { + const [ + codeMeta, + photosCountOutdated, + ] = await Promise.all([ + getGitHubMetaForCurrentApp(), + getOutdatedPhotosCount(), + ]); + + const { + forkBehind, + noAiRateLimiting, + outdatedPhotos, + } = getSignificantInsights({ + codeMeta, + photosCountOutdated, + }); + + if (noAiRateLimiting || outdatedPhotos) { + return 'yellow'; + } else if (forkBehind) { + return 'blue'; + } +}; diff --git a/src/components/cmdk/CommandKClient.tsx b/src/components/cmdk/CommandKClient.tsx index 2f5682ea..1c8e6a56 100644 --- a/src/components/cmdk/CommandKClient.tsx +++ b/src/components/cmdk/CommandKClient.tsx @@ -44,7 +44,7 @@ import { TbPhoto } from 'react-icons/tb'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoSmall from '@/photo/PhotoSmall'; -import { FaCheck, FaCircle } from 'react-icons/fa6'; +import { FaCheck } from 'react-icons/fa6'; import { Tags, addHiddenToTags, formatTag } from '@/tag'; import { FaTag } from 'react-icons/fa'; import { formatCount, formatCountDescriptive } from '@/utility/string'; @@ -52,6 +52,7 @@ import CommandKItem from './CommandKItem'; import { GRID_HOMEPAGE_ENABLED } from '@/app/config'; import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog'; import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; +import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; @@ -102,7 +103,9 @@ export default function CommandKClient({ isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, - hiddenPhotosCount, + photosCountHidden, + uploadsCount, + tagsCount, selectedPhotoIds, setSelectedPhotoIds, insightIndicatorStatus, @@ -228,8 +231,8 @@ export default function CommandKClient({ }, [isOpen, setShouldRespondToKeyboardCommands]); const tagsIncludingHidden = useMemo(() => - addHiddenToTags(tags, hiddenPhotosCount) - , [tags, hiddenPhotosCount]); + addHiddenToTags(tags, photosCountHidden) + , [tags, photosCountHidden]); const SECTION_TAGS: CommandKSection = { heading: 'Tags', @@ -336,70 +339,77 @@ export default function CommandKClient({ const adminSection: CommandKSection = { heading: 'Admin', accessory: , - items: isUserSignedIn - ? ([{ - label: 'Manage Photos', - annotation: , - path: PATH_ADMIN_PHOTOS, - }, { + items: [], + }; + + if (isUserSignedIn) { + adminSection.items.push({ + label: 'Manage Photos', + annotation: , + path: PATH_ADMIN_PHOTOS, + }); + if (uploadsCount) { + adminSection.items.push({ label: 'Manage Uploads', annotation: , path: PATH_ADMIN_UPLOADS, - }, { + }); + } + if (tagsCount) { + adminSection.items.push({ label: 'Manage Tags', annotation: , path: PATH_ADMIN_TAGS, - }, { - label: 'App Config', + }); + } + adminSection.items.push({ + label: + App Insights + {insightIndicatorStatus && + } + , + keywords: ['app insights'], + annotation: , + path: PATH_ADMIN_INSIGHTS, + }, { + label: 'App Config', + annotation: , + path: PATH_ADMIN_CONFIGURATION, + }, { + label: selectedPhotoIds === undefined + ? 'Select Multiple Photos' + : 'Exit Select Multiple Photos', + annotation: , + path: selectedPhotoIds === undefined + ? PATH_GRID_INFERRED + : undefined, + action: selectedPhotoIds === undefined + ? () => setSelectedPhotoIds?.([]) + : () => setSelectedPhotoIds?.(undefined), + }); + if (showDebugTools) { + adminSection.items.push({ + label: 'Baseline Overview', annotation: , - path: PATH_ADMIN_CONFIGURATION, + path: PATH_ADMIN_BASELINE, }, { - label: - App Insights - {insightIndicatorStatus && } - , - keywords: ['app insights'], + label: 'Components Overview', annotation: , - path: PATH_ADMIN_INSIGHTS, - }, { - label: selectedPhotoIds === undefined - ? 'Select Multiple Photos' - : 'Exit Select Multiple Photos', - annotation: , - path: selectedPhotoIds === undefined - ? PATH_GRID_INFERRED - : undefined, - action: selectedPhotoIds === undefined - ? () => setSelectedPhotoIds?.([]) - : () => setSelectedPhotoIds?.(undefined), - }] as CommandKItem[]) - .concat(showDebugTools - ? [{ - label: 'Baseline Overview', - path: PATH_ADMIN_BASELINE, - }, { - label: 'Components Overview', - path: PATH_ADMIN_COMPONENTS, - }] - : []) - .concat({ - label: 'Sign Out', - action: () => { - signOutAndRedirectAction().then(() => setUserEmail?.(undefined)); - }, - }) - : [{ - label: 'Sign In', - path: PATH_SIGN_IN, - }], - }; + path: PATH_ADMIN_COMPONENTS, + }); + } + adminSection.items.push({ + label: 'Sign Out', + action: () => { + signOutAndRedirectAction().then(() => setUserEmail?.(undefined)); + }, + }); + } else { + adminSection.items.push({ + label: 'Sign In', + path: PATH_SIGN_IN, + }); + } return ( [] + items: ComponentProps[] icon?: ReactNode header?: ReactNode className?: string diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index d623b972..fe54671d 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -9,6 +9,8 @@ import { downloadFileFromBrowser } from '@/utility/url'; export default function MoreMenuItem({ label, + labelComplex, + annotation, icon, href, hrefDownloadName, @@ -17,7 +19,9 @@ export default function MoreMenuItem({ dismissMenu, shouldPreventDefault = true, }: { - label: ReactNode + label: string + labelComplex?: ReactNode + annotation?: string icon?: ReactNode href?: string hrefDownloadName?: string @@ -48,7 +52,7 @@ export default function MoreMenuItem({ disabled={isLoading} className={clsx( 'flex items-center h-9', - 'pl-2 pr-4 py-2 rounded-sm', + 'pl-2 pr-3 py-2 rounded-sm', 'select-none hover:outline-hidden', 'hover:bg-gray-100/90 active:bg-gray-200/75', 'dark:hover:bg-gray-800/60 dark:active:bg-gray-900/80', @@ -92,7 +96,11 @@ export default function MoreMenuItem({ styleAs="link-without-hover" className="translate-y-[1px]" > - {label} + {labelComplex ?? label} + {annotation && + + {annotation} + } ); diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 21c53dae..2288d94a 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -37,11 +37,11 @@ export default function PhotoGridSidebar({ }) { const { start, end } = dateRangeForPhotos(undefined, photosDateRange); - const { hiddenPhotosCount } = useAppState(); + const { photosCountHidden } = useAppState(); const tagsIncludingHidden = useMemo(() => - addHiddenToTags(tags, hiddenPhotosCount) - , [tags, hiddenPhotosCount]); + addHiddenToTags(tags, photosCountHidden) + , [tags, photosCountHidden]); return (
diff --git a/src/photo/actions.ts b/src/photo/actions.ts index b7e71a0f..9b741559 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -22,7 +22,6 @@ import { redirect } from 'next/navigation'; import { deleteFile } from '@/platforms/storage'; import { getPhotosCached, - getPhotosMetaCached, revalidateAdminPaths, revalidateAllKeysAndPaths, revalidatePhoto, @@ -416,10 +415,6 @@ export const streamAiImageQueryAction = async ( export const getImageBlurAction = async (url: string) => runAuthenticatedAdminServerAction(() => blurImageFromUrl(url)); -export const getPhotosHiddenMetaCachedAction = async () => - runAuthenticatedAdminServerAction(() => - getPhotosMetaCached({ hidden: 'only' })); - // Public/Private actions export const getPhotosAction = async ( diff --git a/src/state/AppState.ts b/src/state/AppState.ts index ebd1f451..8deaa844 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -26,7 +26,10 @@ export interface AppStateContext { isUserSignedIn?: boolean adminUpdateTimes?: Date[] registerAdminUpdate?: () => void - hiddenPhotosCount?: number + photosCount?: number + photosCountHidden?: number + uploadsCount?: number + tagsCount?: number selectedPhotoIds?: string[] setSelectedPhotoIds?: Dispatch> isPerformingSelectEdit?: boolean diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 28dc7455..a961194e 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -12,11 +12,10 @@ import { MATTE_PHOTOS, SHOW_ZOOM_CONTROLS, } from '@/app/config'; -import { getPhotosHiddenMetaCachedAction } from '@/photo/actions'; import { ShareModalProps } from '@/share'; import { storeTimezoneCookie } from '@/utility/timezone'; -import { getShouldShowInsightsIndicatorAction } from '@/admin/insights/actions'; import { InsightIndicatorStatus } from '@/admin/insights'; +import { getAdminDataAction } from '@/admin/actions'; export default function AppStateProvider({ children, @@ -44,8 +43,14 @@ export default function AppStateProvider({ useState(); const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); - const [hiddenPhotosCount, setHiddenPhotosCount] = - useState(0); + const [photosCount, setPhotosCount] = + useState(); + const [photosCountHidden, setPhotosCountHidden] = + useState(); + const [uploadsCount, setUploadsCount] = + useState(); + const [tagsCount, setTagsCount] = + useState(); const [selectedPhotoIds, setSelectedPhotoIds] = useState(); const [isPerformingSelectEdit, setIsPerformingSelectEdit] = @@ -70,26 +75,37 @@ export default function AppStateProvider({ const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); - const { data, error } = useSWR('getAuth', getAuthAction); + const { data: auth, error: authError } = useSWR('getAuth', getAuthAction); useEffect(() => { - if (!error) { - setUserEmail(data?.user?.email ?? undefined); + if (!authError) { + setUserEmail(auth?.user?.email ?? undefined); } - }, [data, error]); + }, [auth, authError]); const isUserSignedIn = Boolean(userEmail); + + const { data: adminData, error: adminError } = useSWR( + isUserSignedIn ? 'getAdminData' : null, + getAdminDataAction, { + refreshInterval: 1000 * 60 * 5, + }, + ); + useEffect(() => { if (isUserSignedIn) { - const timeout = setTimeout(() =>{ - getPhotosHiddenMetaCachedAction() - .then(({ count }) => setHiddenPhotosCount(count)); - getShouldShowInsightsIndicatorAction() - .then(setInsightIndicatorStatus); - }, 100); - return () => clearTimeout(timeout); + if (adminData) { + const timeout = setTimeout(() => { + setPhotosCount(adminData.countPhotos); + setPhotosCountHidden(adminData.countHiddenPhotos); + setUploadsCount(adminData.countUploads); + setTagsCount(adminData.countTags); + setInsightIndicatorStatus(adminData.shouldShowInsightsIndicator); + }, 100); + return () => clearTimeout(timeout); + } } else { - setHiddenPhotosCount(0); + setPhotosCountHidden(0); } - }, [isUserSignedIn]); + }, [adminData, adminError, isUserSignedIn]); const registerAdminUpdate = useCallback(() => setAdminUpdateTimes(updates => [...updates, new Date()]) @@ -125,7 +141,10 @@ export default function AppStateProvider({ isUserSignedIn, adminUpdateTimes, registerAdminUpdate, - hiddenPhotosCount, + photosCount, + photosCountHidden, + uploadsCount, + tagsCount, selectedPhotoIds, setSelectedPhotoIds, isPerformingSelectEdit, diff --git a/src/tag/index.ts b/src/tag/index.ts index 0bd2fb77..29cc8805 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -105,11 +105,11 @@ export const isPathFavs = (pathname?: string) => export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN; -export const addHiddenToTags = (tags: Tags, hiddenPhotosCount = 0) => { - if (hiddenPhotosCount > 0) { +export const addHiddenToTags = (tags: Tags, photosCountHidden = 0) => { + if (photosCountHidden > 0) { return tags .filter(({ tag }) => tag === TAG_FAVS) - .concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount }) + .concat({ tag: TAG_HIDDEN, count: photosCountHidden }) .concat(tags.filter(({ tag }) => tag !== TAG_FAVS)); } else { return tags;