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",
"ARROWRIGHT",
"Astia",
"authjs",
"camelcase",
"cloudflarestorage",
"cmdk",

View File

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

View File

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

View File

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

View File

@ -41,6 +41,9 @@ export const signInAction = async (
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) =>
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,
onClick,
active,
isInteractive = true,
noPadding,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
}: {
@ -20,21 +21,24 @@ export default function SwitcherItem({
className?: string
onClick?: () => void
active?: boolean
isInteractive?: boolean
noPadding?: boolean
prefetch?: boolean
}) {
const className = clsx(
classNameProp,
'flex items-center justify-center',
'w-[42px] h-full',
'py-0.5 px-1.5',
'cursor-pointer',
'hover:bg-gray-100/60 active:bg-gray-100',
'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
isInteractive && 'cursor-pointer',
isInteractive && 'hover:bg-gray-100/60 active:bg-gray-100',
isInteractive && 'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
active
? 'text-black dark:text-white'
: 'text-gray-400 dark:text-gray-600',
active
? 'hover:text-black dark:hover:text-white'
: 'hover:text-gray-700 dark:hover:text-gray-400',
classNameProp,
);
const renderIcon = () => noPadding
@ -54,6 +58,8 @@ export default function SwitcherItem({
}}>
{renderIcon()}
</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 { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAndRedirectAction } from '@/auth/actions';
import { signOutAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
@ -101,7 +101,7 @@ export default function CommandKClient({
const {
isUserSignedIn,
setUserEmail,
clearAuthStateAndRedirect,
isCommandKOpen: isOpen,
photosCountHidden,
uploadsCount,
@ -400,9 +400,7 @@ export default function CommandKClient({
}
adminSection.items.push({
label: 'Sign Out',
action: () => {
signOutAndRedirectAction().then(() => setUserEmail?.(undefined));
},
action: () => signOutAction().then(clearAuthStateAndRedirect),
});
} else {
adminSection.items.push({

View File

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

View File

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

View File

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

View File

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

View File

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