Eagerly load admin nav with client-side cookie strategy

This commit is contained in:
Sam Becker 2025-02-26 19:45:18 -06:00
parent ac19ed2215
commit 2a0e898ba6
13 changed files with 112 additions and 47 deletions

View File

@ -6,6 +6,7 @@
"ARROWLEFT", "ARROWLEFT",
"ARROWRIGHT", "ARROWRIGHT",
"Astia", "Astia",
"authjs",
"camelcase", "camelcase",
"cloudflarestorage", "cloudflarestorage",
"cmdk", "cmdk",

View File

@ -17,7 +17,7 @@ import { FiTag } from 'react-icons/fi';
import { BiLockAlt } from 'react-icons/bi'; import { BiLockAlt } from 'react-icons/bi';
import AdminAppInfoIcon from './AdminAppInfoIcon'; import AdminAppInfoIcon from './AdminAppInfoIcon';
import { PiSignOutBold } from 'react-icons/pi'; import { PiSignOutBold } from 'react-icons/pi';
import { signOutAndRedirectAction } from '@/auth/actions'; import { signOutAction } from '@/auth/actions';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { FaRegFolderOpen } from 'react-icons/fa'; import { FaRegFolderOpen } from 'react-icons/fa';
@ -34,6 +34,7 @@ export default function AdminAppMenu({
tagsCount, tagsCount,
selectedPhotoIds, selectedPhotoIds,
setSelectedPhotoIds, setSelectedPhotoIds,
clearAuthStateAndRedirect,
} = useAppState(); } = useAppState();
const isSelecting = selectedPhotoIds !== undefined; const isSelecting = selectedPhotoIds !== undefined;
@ -107,7 +108,7 @@ export default function AdminAppMenu({
}, { }, {
label: 'Sign Out', label: 'Sign Out',
icon: <PiSignOutBold size={15} />, icon: <PiSignOutBold size={15} />,
action: signOutAndRedirectAction, action: () => signOutAction().then(clearAuthStateAndRedirect),
}); });
return ( return (

View File

@ -9,7 +9,7 @@ import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths'; import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAndRedirectAction } from '@/auth/actions'; import { signOutAction } from '@/auth/actions';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
@ -17,7 +17,7 @@ import { useAppState } from '@/state/AppState';
export default function Footer() { export default function Footer() {
const pathname = usePathname(); const pathname = usePathname();
const { userEmail, setUserEmail } = useAppState(); const { userEmail, clearAuthStateAndRedirect } = useAppState();
const showFooter = !isPathSignIn(pathname); const showFooter = !isPathSignIn(pathname);
@ -48,8 +48,8 @@ export default function Footer() {
)}> )}>
{userEmail} {userEmail}
</div> </div>
<form action={() => signOutAndRedirectAction() <form action={() => signOutAction()
.then(() => setUserEmail?.(undefined))}> .then(clearAuthStateAndRedirect)}>
<SubmitButtonWithStatus styleAs="link"> <SubmitButtonWithStatus styleAs="link">
Sign out Sign out
</SubmitButtonWithStatus> </SubmitButtonWithStatus>

View File

@ -11,6 +11,7 @@ import { useAppState } from '@/state/AppState';
import { GRID_HOMEPAGE_ENABLED } from './config'; import { GRID_HOMEPAGE_ENABLED } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu'; import AdminAppMenu from '@/admin/AdminAppMenu';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import Spinner from '@/components/Spinner';
export type SwitcherSelection = 'feed' | 'grid' | 'admin'; export type SwitcherSelection = 'feed' | 'grid' | 'admin';
@ -19,9 +20,13 @@ export default function ViewSwitcher({
}: { }: {
currentSelection?: SwitcherSelection currentSelection?: SwitcherSelection
}) { }) {
const { setIsCommandKOpen, isUserSignedIn } = useAppState(); const {
isUserSignedIn,
isUserSignedInEager,
setIsCommandKOpen,
} = useAppState();
const renderItemFeed = () => const renderItemFeed =
<SwitcherItem <SwitcherItem
icon={<IconFeed />} icon={<IconFeed />}
href={PATH_FEED_INFERRED} href={PATH_FEED_INFERRED}
@ -29,7 +34,7 @@ export default function ViewSwitcher({
noPadding noPadding
/>; />;
const renderItemGrid = () => const renderItemGrid =
<SwitcherItem <SwitcherItem
icon={<IconGrid />} icon={<IconGrid />}
href={PATH_GRID_INFERRED} href={PATH_GRID_INFERRED}
@ -40,19 +45,29 @@ export default function ViewSwitcher({
return ( return (
<div className="flex gap-1 sm:gap-2"> <div className="flex gap-1 sm:gap-2">
<Switcher> <Switcher>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid() : renderItemFeed()} {GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFeed}
{GRID_HOMEPAGE_ENABLED ? renderItemFeed() : renderItemGrid()} {GRID_HOMEPAGE_ENABLED ? renderItemFeed : renderItemGrid}
{/* Show spinner if admin is suspected to be logged in */}
{(isUserSignedInEager && !isUserSignedIn) &&
<SwitcherItem
icon={<Spinner />}
isInteractive={false}
noPadding
/>}
{isUserSignedIn && {isUserSignedIn &&
<AdminAppMenu <SwitcherItem
className="mt-3 ml-[-84px]" icon={<AdminAppMenu
buttonClassName={clsx( className="mt-3 ml-[-94px]"
'w-[40px] h-[28px]', buttonClassName={clsx(
'flex items-center justify-center', 'bg-transparent dark:bg-transparent',
'active:bg-transparent', 'hover:bg-transparent dark:hover:bg-transparent',
currentSelection === 'admin' 'active:bg-transparent dark:active:bg-transparent',
? 'text-black dark:text-white' currentSelection === 'admin'
: 'text-gray-400 dark:text-gray-600', ? 'text-black dark:text-white'
)} : 'text-gray-400 dark:text-gray-600',
)}
/>}
noPadding
/>} />}
</Switcher> </Switcher>
<Switcher type="borderless"> <Switcher type="borderless">

View File

@ -41,6 +41,9 @@ export const signInAction = async (
redirect(formData.get(KEY_CALLBACK_URL) as string || PATH_ADMIN_PHOTOS); redirect(formData.get(KEY_CALLBACK_URL) as string || PATH_ADMIN_PHOTOS);
}; };
export const signOutAction = async () =>
signOut({ redirect: false });
export const signOutAndRedirectAction = async (redirectTo = PATH_SIGN_IN) => export const signOutAndRedirectAction = async (redirectTo = PATH_SIGN_IN) =>
signOut({ redirectTo }); signOut({ redirectTo });

12
src/auth/client.ts Normal file
View File

@ -0,0 +1,12 @@
import { deleteCookie, getCookie, storeCookie } from '@/utility/cookie';
const KEY_AUTH_EMAIL = 'authjs.email';
export const storeAuthEmailCookie = (email: string) =>
storeCookie(KEY_AUTH_EMAIL, email);
export const clearAuthEmailCookie = () =>
deleteCookie(KEY_AUTH_EMAIL);
export const hasAuthEmailCookie = () =>
Boolean(getCookie(KEY_AUTH_EMAIL));

View File

@ -11,6 +11,7 @@ export default function SwitcherItem({
className: classNameProp, className: classNameProp,
onClick, onClick,
active, active,
isInteractive = true,
noPadding, noPadding,
prefetch = SHOULD_PREFETCH_ALL_LINKS, prefetch = SHOULD_PREFETCH_ALL_LINKS,
}: { }: {
@ -20,21 +21,24 @@ export default function SwitcherItem({
className?: string className?: string
onClick?: () => void onClick?: () => void
active?: boolean active?: boolean
isInteractive?: boolean
noPadding?: boolean noPadding?: boolean
prefetch?: boolean prefetch?: boolean
}) { }) {
const className = clsx( const className = clsx(
classNameProp, 'flex items-center justify-center',
'w-[42px] h-full',
'py-0.5 px-1.5', 'py-0.5 px-1.5',
'cursor-pointer', isInteractive && 'cursor-pointer',
'hover:bg-gray-100/60 active:bg-gray-100', isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
'dark:hover:bg-gray-900/75 dark:active:bg-gray-900', isInteractive && 'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
active active
? 'text-black dark:text-white' ? 'text-black dark:text-white'
: 'text-gray-400 dark:text-gray-600', : 'text-gray-400 dark:text-gray-600',
active active
? 'hover:text-black dark:hover:text-white' ? 'hover:text-black dark:hover:text-white'
: 'hover:text-gray-700 dark:hover:text-gray-400', : 'hover:text-gray-700 dark:hover:text-gray-400',
classNameProp,
); );
const renderIcon = () => noPadding const renderIcon = () => noPadding
@ -54,6 +58,8 @@ export default function SwitcherItem({
}}> }}>
{renderIcon()} {renderIcon()}
</LinkWithLoader> </LinkWithLoader>
: <div {...{ title, onClick, className }}>{renderIcon()}</div> : <div {...{ title, onClick, className }}>
{renderIcon()}
</div>
); );
}; };

View File

@ -39,7 +39,7 @@ import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri'; import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi'; import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi'; import { HiDocumentText } from 'react-icons/hi';
import { signOutAndRedirectAction } from '@/auth/actions'; import { signOutAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb'; import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate'; import PhotoDate from '@/photo/PhotoDate';
@ -101,7 +101,7 @@ export default function CommandKClient({
const { const {
isUserSignedIn, isUserSignedIn,
setUserEmail, clearAuthStateAndRedirect,
isCommandKOpen: isOpen, isCommandKOpen: isOpen,
photosCountHidden, photosCountHidden,
uploadsCount, uploadsCount,
@ -400,9 +400,7 @@ export default function CommandKClient({
} }
adminSection.items.push({ adminSection.items.push({
label: 'Sign Out', label: 'Sign Out',
action: () => { action: () => signOutAction().then(clearAuthStateAndRedirect),
signOutAndRedirectAction().then(() => setUserEmail?.(undefined));
},
}); });
} else { } else {
adminSection.items.push({ adminSection.items.push({

View File

@ -197,7 +197,6 @@ export const formHasTextContent = ({
// CREATE FORM DATA: FROM PHOTO // CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (photo: Photo): PhotoFormData => { export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
console.log('convertPhotoToFormData', photo);
const valueForKey = (key: keyof Photo, value: any) => { const valueForKey = (key: keyof Photo, value: any) => {
switch (key) { switch (key) {
case 'tags': case 'tags':

View File

@ -133,8 +133,6 @@ export const getGitHubPublicFork = async (): Promise<RepoParams> => {
}; };
export const getGitHubMeta = async (params: RepoParams) => { export const getGitHubMeta = async (params: RepoParams) => {
console.log('getGitHubMeta', params);
const urlOwner = getGitHubUrlOwner(params); const urlOwner = getGitHubUrlOwner(params);
const urlRepo = getGitHubUrlRepo(params); const urlRepo = getGitHubUrlRepo(params);
const urlBranch = getGitHubUrlBranch(params); const urlBranch = getGitHubUrlBranch(params);

View File

@ -20,10 +20,13 @@ export interface AppStateContext {
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>> setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
shareModalProps?: ShareModalProps shareModalProps?: ShareModalProps
setShareModalProps?: Dispatch<SetStateAction<ShareModalProps | undefined>> setShareModalProps?: Dispatch<SetStateAction<ShareModalProps | undefined>>
// ADMIN // AUTH
userEmail?: string userEmail?: string
setUserEmail?: Dispatch<SetStateAction<string | undefined>> setUserEmail?: Dispatch<SetStateAction<string | undefined>>
isUserSignedIn?: boolean isUserSignedIn?: boolean
isUserSignedInEager?: boolean
clearAuthStateAndRedirect?: () => void
// ADMIN
adminUpdateTimes?: Date[] adminUpdateTimes?: Date[]
registerAdminUpdate?: () => void registerAdminUpdate?: () => void
photosCount?: number photosCount?: number

View File

@ -16,6 +16,13 @@ import { ShareModalProps } from '@/share';
import { storeTimezoneCookie } from '@/utility/timezone'; import { storeTimezoneCookie } from '@/utility/timezone';
import { InsightIndicatorStatus } from '@/admin/insights'; import { InsightIndicatorStatus } from '@/admin/insights';
import { getAdminDataAction } from '@/admin/actions'; import { getAdminDataAction } from '@/admin/actions';
import {
storeAuthEmailCookie,
clearAuthEmailCookie,
hasAuthEmailCookie,
} from '@/auth/client';
import { useRouter } from 'next/navigation';
import { PATH_SIGN_IN } from '@/app/paths';
export default function AppStateProvider({ export default function AppStateProvider({
children, children,
@ -24,6 +31,8 @@ export default function AppStateProvider({
}) { }) {
const { previousPathname } = usePathnames(); const { previousPathname } = usePathnames();
const router = useRouter();
// CORE // CORE
const [hasLoaded, setHasLoaded] = const [hasLoaded, setHasLoaded] =
useState(false); useState(false);
@ -41,6 +50,9 @@ export default function AppStateProvider({
// ADMIN // ADMIN
const [userEmail, setUserEmail] = const [userEmail, setUserEmail] =
useState<string>(); useState<string>();
const [isUserSignedInEager, setIsUserSignedInEager] =
useState(false);
// ADMIN
const [adminUpdateTimes, setAdminUpdateTimes] = const [adminUpdateTimes, setAdminUpdateTimes] =
useState<Date[]>([]); useState<Date[]>([]);
const [photosCount, setPhotosCount] = const [photosCount, setPhotosCount] =
@ -77,6 +89,7 @@ export default function AppStateProvider({
const { data: auth, error: authError } = useSWR('getAuth', getAuthAction); const { data: auth, error: authError } = useSWR('getAuth', getAuthAction);
useEffect(() => { useEffect(() => {
setIsUserSignedInEager(hasAuthEmailCookie());
if (!authError) { if (!authError) {
setUserEmail(auth?.user?.email ?? undefined); setUserEmail(auth?.user?.email ?? undefined);
} }
@ -91,7 +104,8 @@ export default function AppStateProvider({
); );
useEffect(() => { useEffect(() => {
if (isUserSignedIn) { if (userEmail) {
storeAuthEmailCookie(userEmail);
if (adminData) { if (adminData) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setPhotosCount(adminData.countPhotos); setPhotosCount(adminData.countPhotos);
@ -105,7 +119,7 @@ export default function AppStateProvider({
} else { } else {
setPhotosCountHidden(0); setPhotosCountHidden(0);
} }
}, [adminData, adminError, isUserSignedIn]); }, [adminData, adminError, userEmail]);
const registerAdminUpdate = useCallback(() => const registerAdminUpdate = useCallback(() =>
setAdminUpdateTimes(updates => [...updates, new Date()]) setAdminUpdateTimes(updates => [...updates, new Date()])
@ -116,6 +130,12 @@ export default function AppStateProvider({
storeTimezoneCookie(); storeTimezoneCookie();
}, []); }, []);
const clearAuthStateAndRedirect = useCallback((shouldRedirect = true) => {
setUserEmail(undefined);
clearAuthEmailCookie();
if (shouldRedirect) { router.push(PATH_SIGN_IN); }
}, [router]);
return ( return (
<AppStateContext.Provider <AppStateContext.Provider
value={{ value={{
@ -135,10 +155,13 @@ export default function AppStateProvider({
setIsCommandKOpen, setIsCommandKOpen,
shareModalProps, shareModalProps,
setShareModalProps, setShareModalProps,
// ADMIN // AUTH
userEmail, userEmail,
setUserEmail, setUserEmail,
isUserSignedIn, isUserSignedIn,
isUserSignedInEager,
clearAuthStateAndRedirect,
// ADMIN
adminUpdateTimes, adminUpdateTimes,
registerAdminUpdate, registerAdminUpdate,
photosCount, photosCount,

View File

@ -5,19 +5,25 @@ export const storeCookie = (
maxAge = 63158400, maxAge = 63158400,
sameSite = 'Lax', sameSite = 'Lax',
) => { ) => {
document.cookie = if (typeof document !== 'undefined') {
`${name}=${value};Path=${path};Max-Age=${maxAge};SameSite=${sameSite}`; document.cookie =
`${name}=${value};Path=${path};Max-Age=${maxAge};SameSite=${sameSite}`;
}
}; };
export const getCookie = (name: string) => { export const getCookie = (name: string) => {
const cookie: Record<string, string> = {}; if (typeof document !== 'undefined') {
document.cookie.split(';').forEach(function(el) { const cookie: Record<string, string> = {};
const split = el.split('='); document.cookie.split(';').forEach(function(el) {
cookie[split[0].trim()] = split.slice(1).join('='); const split = el.split('=');
}); cookie[split[0].trim()] = split.slice(1).join('=');
return cookie[name]; });
return cookie[name];
}
}; };
export const deleteCookie = (name: string) => { export const deleteCookie = (name: string) => {
document.cookie = `${name}=;Max-Age=0`; if (typeof document !== 'undefined') {
document.cookie = `${name}=;Max-Age=0`;
}
}; };