diff --git a/src/app/tag/hidden/page.tsx b/src/app/tag/hidden/page.tsx index 1c2b894a..796ff9e2 100644 --- a/src/app/tag/hidden/page.tsx +++ b/src/app/tag/hidden/page.tsx @@ -50,7 +50,7 @@ export default async function HiddenTagPage() { ]); return ( + contentMain={
]} animateOnFirstLoadOnly /> - - Only authenticated admins can see hidden photos. - - +
+ + Only visible to authenticated admins + + +
} /> ); diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx index c57696d5..c2d570e2 100644 --- a/src/components/CommandKClient.tsx +++ b/src/components/CommandKClient.tsx @@ -17,6 +17,7 @@ import { PATH_ADMIN_UPLOADS, PATH_SIGN_IN, pathForPhoto, + pathForTag, } from '../site/paths'; import Modal from './Modal'; import { clsx } from 'clsx/lite'; @@ -37,6 +38,9 @@ import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoTiny from '@/photo/PhotoTiny'; import { FaCheck } from 'react-icons/fa6'; +import { TagsWithMeta, addHiddenToTags } from '@/tag'; +import { FaTag } from 'react-icons/fa'; +import { formatCount, formatCountDescriptive } from '@/utility/string'; const LISTENER_KEYDOWN = 'keydown'; const MINIMUM_QUERY_LENGTH = 2; @@ -44,9 +48,9 @@ const MINIMUM_QUERY_LENGTH = 2; type CommandKItem = { label: string keywords?: string[] + accessory?: ReactNode annotation?: ReactNode annotationAria?: string - accessory?: ReactNode path?: string action?: () => void | Promise } @@ -58,10 +62,12 @@ export type CommandKSection = { } export default function CommandKClient({ + tags, serverSections = [], showDebugTools, footer, }: { + tags: TagsWithMeta serverSections?: CommandKSection[] showDebugTools?: boolean footer?: string @@ -70,6 +76,7 @@ export default function CommandKClient({ isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, + hiddenPhotosCount, arePhotosMatted, shouldShowBaselineGrid, shouldDebugBlur, @@ -173,6 +180,24 @@ export default function CommandKClient({ } }, [isOpen, setShouldRespondToKeyboardCommands]); + const tagsIncludingHidden = useMemo(() => + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + + const SECTION_TAGS: CommandKSection = { + heading: 'Tags', + accessory: , + items: tagsIncludingHidden.map(({ tag, count }) => ({ + label: tag, + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForTag(tag), + })), + }; + const clientSections: CommandKSection[] = [{ heading: 'Theme', accessory: {queriedSections + .concat(SECTION_TAGS) .concat(serverSections) .concat(sectionPages) .concat(adminSection) diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 09b1a6c0..0199ff63 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Cameras, sortCamerasWithCount } from '@/camera'; import PhotoCamera from '@/camera/PhotoCamera'; import HeaderList from '@/components/HeaderList'; @@ -5,11 +7,14 @@ import PhotoTag from '@/tag/PhotoTag'; import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.'; -import { TAG_FAVS, TagsWithMeta } from '@/tag'; +import { TAG_FAVS, TAG_HIDDEN, TagsWithMeta, addHiddenToTags } from '@/tag'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation'; import FavsTag from '../tag/FavsTag'; +import { useAppState } from '@/state/AppState'; +import { useMemo } from 'react'; +import HiddenTag from '@/tag/HiddenTag'; export default function PhotoGridSidebar({ tags, @@ -26,29 +31,49 @@ export default function PhotoGridSidebar({ }) { const { start, end } = dateRangeForPhotos(undefined, photosDateRange); + const { hiddenPhotosCount } = useAppState(); + + const tagsIncludingHidden = useMemo(() => + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + return ( <> {tags.length > 0 && } - items={tags.map(({ tag, count }) => tag === TAG_FAVS - ? - : )} + items={tagsIncludingHidden.map(({ tag, count }) => { + switch (tag) { + case TAG_FAVS: + return ; + case TAG_HIDDEN: + return ; + default: + return ; + } + })} />} {cameras.length > 0 && safelyRunAdminServerAction(() => blurImageFromUrl(url)); -// Public actions +export const getPhotosTagHiddenMetaCachedAction = async () => + safelyRunAdminServerAction(getPhotosTagHiddenMetaCached); + +// Public/Private actions export const getPhotosAction = async ( offset: number, limit: number, hidden?: GetPhotosOptions['hidden'], -) => - getPhotos({ offset, hidden, limit }); +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotos({ offset, hidden, limit })) + : getPhotos({ offset, hidden, limit }); export const getPhotosCachedAction = async ( offset: number, limit: number, hidden?: GetPhotosOptions['hidden'], -) => - getPhotosCachedCached({ offset, hidden, limit }); +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotosCachedCached({ offset, hidden, limit })) + : getPhotosCachedCached({ offset, hidden, limit }); + +// Public actions export const queryPhotosByTitleAction = async (query: string) => (await getPhotos({ query, limit: 10 })) diff --git a/src/site/CommandK.tsx b/src/site/CommandK.tsx index 77c7f39e..e62f87c8 100644 --- a/src/site/CommandK.tsx +++ b/src/site/CommandK.tsx @@ -8,14 +8,12 @@ import { import { pathForCamera, pathForFilmSimulation, - pathForTag, } from './paths'; import { formatCameraText } from '@/camera'; import { photoQuantityText } from '@/photo'; import { formatCount, formatCountDescriptive } from '@/utility/string'; -import { sortTagsObject } from '@/tag'; +import { TagsWithMeta } from '@/tag'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; -import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { ADMIN_DEBUG_TOOLS_ENABLED } from './config'; @@ -27,25 +25,11 @@ export default async function CommandK() { filmSimulations, ] = await Promise.all([ getPhotosCountCached().catch(() => 0), - getUniqueTagsCached().catch(() => []), + getUniqueTagsCached().catch(() => [] as TagsWithMeta), getUniqueCamerasCached().catch(() => []), getUniqueFilmSimulationsCached().catch(() => []), ]); - const SECTION_TAGS: CommandKSection = { - heading: 'Tags', - accessory: , - items: sortTagsObject(tags).map(({ tag, count }) => ({ - label: tag, - annotation: formatCount(count), - annotationAria: formatCountDescriptive(count), - path: pathForTag(tag), - })), - }; - const SECTION_CAMERAS: CommandKSection = { heading: 'Cameras', accessory: , @@ -71,8 +55,8 @@ export default async function CommandK() { }; return > + setHasLoaded?: Dispatch> swrTimestamp?: number invalidateSwr?: () => void - userEmail?: string - setUserEmail?: Dispatch> - isUserSignedIn?: boolean - setHasLoaded?: Dispatch> nextPhotoAnimation?: AnimationConfig setNextPhotoAnimation?: Dispatch> + clearNextPhotoAnimation?: () => void shouldRespondToKeyboardCommands?: boolean setShouldRespondToKeyboardCommands?: Dispatch> isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> + // ADMIN + userEmail?: string + setUserEmail?: Dispatch> + isUserSignedIn?: boolean adminUpdateTimes?: Date[] registerAdminUpdate?: () => void - shouldShowBaselineGrid?: boolean - setShouldShowBaselineGrid?: Dispatch> + hiddenPhotosCount?: number + // DEBUG + arePhotosMatted?: boolean + setArePhotosMatted?: Dispatch> shouldDebugBlur?: boolean setShouldDebugBlur?: Dispatch> - clearNextPhotoAnimation?: () => void + shouldShowBaselineGrid?: boolean + setShouldShowBaselineGrid?: Dispatch> } export const AppStateContext = createContext({}); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index d9967814..524053be 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -7,6 +7,7 @@ import usePathnames from '@/utility/usePathnames'; import { getAuthAction, logClientAuthUpdate } from '@/auth/actions'; import useSWR from 'swr'; import { MATTE_PHOTOS } from '@/site/config'; +import { getPhotosTagHiddenMetaCachedAction } from '@/photo/actions'; export default function AppStateProvider({ children, @@ -15,25 +16,31 @@ export default function AppStateProvider({ }) { const { previousPathname } = usePathnames(); + // CORE const [hasLoaded, setHasLoaded] = useState(false); - const [arePhotosMatted, setArePhotosMatted] = - useState(MATTE_PHOTOS); const [swrTimestamp, setSwrTimestamp] = useState(Date.now()); - const [userEmail, setUserEmail] = - useState(); const [nextPhotoAnimation, setNextPhotoAnimation] = useState(); const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] = useState(true); const [isCommandKOpen, setIsCommandKOpen] = useState(false); - const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); - const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = - useState(false); + // ADMIN + const [userEmail, setUserEmail] = + useState(); + const [adminUpdateTimes, setAdminUpdateTimes] = + useState([]); + const [hiddenPhotosCount, setHiddenPhotosCount] = + useState(0); + // DEBUG + const [arePhotosMatted, setArePhotosMatted] = + useState(MATTE_PHOTOS); const [shouldDebugBlur, setShouldDebugBlur] = useState(false); + const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = + useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -42,6 +49,13 @@ export default function AppStateProvider({ setUserEmail(data?.user?.email ?? undefined); logClientAuthUpdate(data); }, [data]); + const isUserSignedIn = userEmail !== undefined; + useEffect(() => { + if (isUserSignedIn) { + getPhotosTagHiddenMetaCachedAction().then(({ count }) => + setHiddenPhotosCount(count)); + } + }, [isUserSignedIn]); const registerAdminUpdate = useCallback(() => setAdminUpdateTimes(updates => [...updates, new Date()]) @@ -54,29 +68,33 @@ export default function AppStateProvider({ return ( setNextPhotoAnimation?.(undefined), shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands, isCommandKOpen, setIsCommandKOpen, + // ADMIN + userEmail, + setUserEmail, + isUserSignedIn, adminUpdateTimes, registerAdminUpdate, - shouldShowBaselineGrid, - shouldDebugBlur, + hiddenPhotosCount, + // DEBUG + arePhotosMatted, + setArePhotosMatted, setShouldDebugBlur, setShouldShowBaselineGrid, - clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), + shouldShowBaselineGrid, + shouldDebugBlur, }} > {children} diff --git a/src/tag/HiddenTag.tsx b/src/tag/HiddenTag.tsx index ba8e673a..e9b176f2 100644 --- a/src/tag/HiddenTag.tsx +++ b/src/tag/HiddenTag.tsx @@ -19,7 +19,10 @@ export default function HiddenTag({ label={badged ? {TAG_HIDDEN} - + : TAG_HIDDEN} href={pathForTag(TAG_HIDDEN)} diff --git a/src/tag/index.ts b/src/tag/index.ts index a1dc3bbd..fc85ee32 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -91,3 +91,14 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs); export const isPathFavs = (pathname?: string) => getPathComponents(pathname).tag === TAG_FAVS; + +export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => { + if (hiddenPhotosCount > 0) { + return tags + .filter(({ tag }) => tag === TAG_FAVS) + .concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount }) + .concat(tags.filter(({ tag }) => tag !== TAG_FAVS)); + } else { + return tags; + } +};