Move auth to client state

This commit is contained in:
Sam Becker 2024-04-21 22:36:49 -05:00
parent 2f11e8b0cf
commit ef1c8fc79d
15 changed files with 266 additions and 268 deletions

View File

@ -9,6 +9,7 @@ import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/MoreMenu';
import { useAppState } from '@/state/AppState';
export default function AdminPhotoMenuClient({
photo,
@ -16,14 +17,16 @@ export default function AdminPhotoMenuClient({
}: Omit<ComponentProps<typeof MoreMenu>, 'items'> & {
photo: Photo
}) {
const { isUserSignedIn } = useAppState();
const isFav = isPhotoFav(photo);
const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav;
const shouldRedirectDelete = pathForPhoto(photo.id) === path;
return (
<>
<MoreMenu {...{
isUserSignedIn
? <MoreMenu {...{
items: [
{
label: 'Edit',
@ -63,6 +66,6 @@ export default function AdminPhotoMenuClient({
],
...props,
}}/>
</>
: null
);
}

View File

@ -1,6 +1,4 @@
import AdminNav from '@/admin/AdminNav';
import AdminNavClient from '@/admin/AdminNavClient';
import { Suspense } from 'react';
export default async function AdminLayout({
children,
@ -9,13 +7,7 @@ export default async function AdminLayout({
}) {
return (
<div className="mt-4 space-y-5">
<Suspense fallback={<AdminNavClient items={[{
label: 'Photos',
count: 0,
href: '/admin/photos',
}]} />}>
<AdminNav />
</Suspense>
<AdminNav />
{children}
</div>
);

View File

@ -11,7 +11,6 @@ import { Metadata } from 'next/types';
import PhotoGridSidebar from '@/photo/PhotoGridSidebar';
import { getPhotoSidebarDataCached } from '@/photo/data';
import { MorePhotosGrid } from '@/photo/MorePhotosGrid';
import { Suspense } from 'react';
export const revalidate = 3600;
@ -37,13 +36,11 @@ export default async function GridPage() {
? <SiteGrid
contentMain={<div className="space-y-0.5 sm:space-y-1">
<PhotoGrid {...{ photos, photoPriority: true }} />
<Suspense>
<MorePhotosGrid
initialOffset={INFINITE_SCROLL_MULTIPLE_GRID}
itemsPerRequest={INFINITE_SCROLL_MULTIPLE_GRID}
totalPhotosCount={photosCount}
/>
</Suspense>
<MorePhotosGrid
initialOffset={INFINITE_SCROLL_MULTIPLE_GRID}
itemsPerRequest={INFINITE_SCROLL_MULTIPLE_GRID}
totalPhotosCount={photosCount}
/>
</div>}
contentSide={<div className="sticky top-4 space-y-4 mt-[-4px]">
<PhotoGridSidebar {...{

View File

@ -9,12 +9,12 @@ import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
import { Metadata } from 'next/types';
import MoreComponentsProvider from '@/state/MoreComponentsProvider';
import { ThemeProvider } from 'next-themes';
import Nav from '@/site/Nav';
import Footer from '@/site/Footer';
import CommandK from '@/site/CommandK';
import '../site/globals.css';
import '../site/sonner.css';
import NavClient from '@/site/NavClient';
import CommandKClient from '@/components/CommandKClient';
import FooterClient from '@/site/FooterClient';
const ibmPlexMono = IBM_Plex_Mono({
subsets: ['latin'],
@ -78,16 +78,16 @@ export default function RootLayout({
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
)}>
<NavClient />
<Nav />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
)}>
{children}
</div>
<FooterClient />
<Footer />
</main>
<CommandKClient />
<CommandK />
</ThemeProvider>
</MoreComponentsProvider>
<Analytics debug={false} />

View File

@ -3,16 +3,19 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import InfoBlock from '@/components/InfoBlock';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { useLayoutEffect, useRef, useState } from 'react';
import { signInAction } from './actions';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { getCurrentUser, signInAction } from './actions';
import { useFormState } from 'react-dom';
import ErrorNote from '@/components/ErrorNote';
import { KEY_CALLBACK_URL, KEY_CREDENTIALS_SIGN_IN_ERROR } from '.';
import { useSearchParams } from 'next/navigation';
import { useAppState } from '@/state/AppState';
export default function SignInForm() {
const params = useSearchParams();
const { setUserEmail } = useAppState();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [response, action] = useFormState(signInAction, undefined);
@ -22,6 +25,13 @@ export default function SignInForm() {
emailRef.current?.focus();
}, []);
useEffect(() => {
return () => {
// Capture user email before unmounting
getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined));
};
}, [setUserEmail]);
const isFormValid =
email.length > 0 &&
password.length > 0;

View File

@ -4,10 +4,11 @@ import {
KEY_CALLBACK_URL,
KEY_CREDENTIALS_SIGN_IN_ERROR,
KEY_CREDENTIALS_SIGN_IN_ERROR_URL,
auth,
signIn,
signOut,
} from '@/auth';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { PATH_ADMIN_PHOTOS, PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation';
export const signInAction = async (
@ -35,6 +36,7 @@ export const signInAction = async (
redirect(formData.get(KEY_CALLBACK_URL) as string || PATH_ADMIN_PHOTOS);
};
export const signOutAction = async () => {
await signOut();
};
export const signOutAndRedirectAction = async () =>
signOut({ redirectTo: PATH_ROOT });
export const getCurrentUser = async () => (await auth())?.user;

View File

@ -9,6 +9,14 @@ import {
useState,
useTransition,
} from 'react';
import {
PATH_ADMIN_BASELINE,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_SIGN_IN,
} from '../site/paths';
import Modal from './Modal';
import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
@ -20,22 +28,27 @@ import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState';
import { getPhotoItemsAction } 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';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
type CommandKItem = {
label: string
keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
path?: string
action?: () => void | Promise<void>
}
export type CommandKSection = {
heading: string
accessory?: ReactNode
items: {
label: string
keywords?: string[]
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
path?: string
action?: () => void
}[]
items: CommandKItem[]
}
export default function CommandKClient({
@ -48,6 +61,8 @@ export default function CommandKClient({
footer?: string
}) {
const {
isUserSignedIn,
setUserEmail,
isCommandKOpen: isOpen,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
@ -167,6 +182,57 @@ export default function CommandKClient({
});
}
const sectionPages: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
};
const adminSection: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
items: isUserSignedIn
? ([{
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
label: 'Manage Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
label: 'Manage Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}] as CommandKItem[])
.concat(showDebugTools
? [{
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
}]
: [])
.concat({
label: 'Sign Out',
action: () => {
signOutAndRedirectAction().then(() => setUserEmail?.(undefined));
},
})
: [{
label: 'Sign In',
path: PATH_SIGN_IN,
}],
};
return (
<Command.Dialog
open={isOpen}
@ -219,6 +285,8 @@ export default function CommandKClient({
</Command.Empty>
{queriedSections
.concat(serverSections)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>

View File

@ -144,9 +144,9 @@ export default function FieldSetWithStatus({
Boolean(error) && 'error',
)}
/>}
<div>
{accessory && <div>
{accessory}
</div>
</div>}
</div>
</div>
);

View File

@ -6,12 +6,6 @@ import {
getUniqueTagsCached,
} from '@/photo/cache';
import {
PATH_ADMIN_BASELINE,
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_SIGN_IN,
pathForCamera,
pathForFilmSimulation,
pathForTag,
@ -20,13 +14,10 @@ import { formatCameraText } from '@/camera';
import { authCachedSafe } from '@/auth/cache';
import { photoQuantityText } from '@/photo';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { sortTagsObject } from '@/tag';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import { ADMIN_DEBUG_TOOLS_ENABLED } from './config';
export default async function CommandK() {
@ -84,62 +75,11 @@ export default async function CommandK() {
})),
};
const SECTION_PAGES: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
};
const SECTION_ADMIN: CommandKSection = {
heading: 'Admin',
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
items: isAdminLoggedIn
? [{
label: 'Manage Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
label: 'Manage Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
label: 'Manage Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}, {
label: 'Sign Out',
action: signOutAction,
}]
: [{
label: 'Sign In',
path: PATH_SIGN_IN,
}],
};
if (isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED) {
SECTION_ADMIN.items.push({
label: 'Baseline Overview',
path: PATH_ADMIN_BASELINE,
});
}
return <CommandKClient
serverSections={[
SECTION_TAGS,
SECTION_CAMERAS,
SECTION_FILM,
SECTION_PAGES,
SECTION_ADMIN,
]}
showDebugTools={isAdminLoggedIn && ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)}

View File

@ -1,9 +1,75 @@
import { authCachedSafe } from '@/auth/cache';
import FooterClient from './FooterClient';
'use client';
import { clsx } from 'clsx/lite';
import SiteGrid from '../components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/site/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { isPathAdmin, isPathSignIn, pathForAdminPhotos } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAndRedirectAction } from '@/auth/actions';
import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState';
export default function Footer() {
const pathname = usePathname();
const { userEmail, setUserEmail } = useAppState();
const showFooter = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname);
export default async function Footer() {
const session = await authCachedSafe();
return (
<FooterClient userEmail={session?.user?.email} />
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!shouldAnimate ? 'none' : 'bottom'}
distanceOffset={10}
items={showFooter
? [<div
key="footer"
className={clsx(
'flex items-center',
'text-dim min-h-10',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&
<Spinner />}
{userEmail && <>
<div className={clsx(
'truncate max-w-full',
)}>
{userEmail}
</div>
<form action={() => signOutAndRedirectAction()
.then(() => setUserEmail?.(undefined))}>
<SubmitButtonWithStatus styleAsLink>
Sign out
</SubmitButtonWithStatus>
</form>
</>}
</>
: <>
<Link href={pathForAdminPhotos()}>
Admin
</Link>
{SHOW_REPO_LINK &&
<RepoLink />}
</>}
</div>
<div className="flex items-center h-10">
<ThemeSwitcher />
</div>
</div>]
: []}
/>}
/>
);
}

View File

@ -1,75 +0,0 @@
'use client';
import { clsx } from 'clsx/lite';
import SiteGrid from '../components/SiteGrid';
import ThemeSwitcher from '@/site/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/site/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { isPathAdmin, isPathSignIn, pathForAdminPhotos } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAction } from '@/auth/actions';
import Spinner from '@/components/Spinner';
import AnimateItems from '@/components/AnimateItems';
export default function FooterClient({
userEmail,
}: {
userEmail?: string | null | undefined
}) {
const pathname = usePathname();
const showFooter = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname);
return (
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={!shouldAnimate ? 'none' : 'bottom'}
distanceOffset={10}
items={showFooter
? [<div
key="footer"
className={clsx(
'flex items-center',
'text-dim min-h-10',
)}>
<div className="flex gap-x-4 gap-y-0.5 flex-grow flex-wrap">
{isPathAdmin(pathname)
? <>
{userEmail === undefined &&
<Spinner />}
{userEmail && <>
<div className={clsx(
'truncate max-w-full',
)}>
{userEmail}
</div>
<form action={signOutAction}>
<SubmitButtonWithStatus styleAsLink>
Sign out
</SubmitButtonWithStatus>
</form>
</>}
</>
: <>
<Link href={pathForAdminPhotos()}>
Admin
</Link>
{SHOW_REPO_LINK &&
<RepoLink />}
</>}
</div>
<div className="flex items-center h-10">
<ThemeSwitcher />
</div>
</div>]
: []}
/>}
/>
);
}

View File

@ -1,16 +1,76 @@
import { authCachedSafe } from '@/auth/cache';
import NavClient from './NavClient';
'use client';
export default async function Nav({
animate,
import { clsx } from 'clsx/lite';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from '../components/SiteGrid';
import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import {
PATH_ROOT,
isPathGrid,
isPathProtected,
isPathSignIn,
} from '@/site/paths';
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
export default function Nav({
animate = true,
}: {
animate?: boolean
animate?: boolean,
}) {
const session = await authCachedSafe();
const pathname = usePathname();
const { isUserSignedIn } = useAppState();
const showNav = !isPathSignIn(pathname);
const renderLink = (
text: string,
linkOrAction: string | (() => void),
) =>
typeof linkOrAction === 'string'
? <Link href={linkOrAction}>{text}</Link>
: <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === PATH_ROOT) {
return 'full-frame';
} else if (isPathGrid(pathname)) {
return 'grid';
} else if (isPathProtected(pathname)) {
return 'admin';
}
};
return (
<NavClient
showAdmin={Boolean(session?.user?.email)}
animate={animate}
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={animate ? 'bottom' : 'none'}
distanceOffset={10}
items={showNav
? [<div
key="nav"
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
)}>
<div className="flex-grow">
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={isUserSignedIn}
/>
</div>
<div className="hidden xs:block text-right text-balance">
{renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
</div>
</div>]
: []}
/>
}
/>
);
}
};

View File

@ -1,75 +0,0 @@
'use client';
import { clsx } from 'clsx/lite';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import SiteGrid from '../components/SiteGrid';
import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
import ViewSwitcher, { SwitcherSelection } from '@/site/ViewSwitcher';
import {
PATH_ROOT,
isPathGrid,
isPathProtected,
isPathSignIn,
} from '@/site/paths';
import AnimateItems from '../components/AnimateItems';
export default function NavClient({
showAdmin,
animate = true,
}: {
showAdmin?: boolean,
animate?: boolean,
}) {
const pathname = usePathname();
const showNav = !isPathSignIn(pathname);
const renderLink = (
text: string,
linkOrAction: string | (() => void),
) =>
typeof linkOrAction === 'string'
? <Link href={linkOrAction}>{text}</Link>
: <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === PATH_ROOT) {
return 'full-frame';
} else if (isPathGrid(pathname)) {
return 'grid';
} else if (isPathProtected(pathname)) {
return 'admin';
}
};
return (
<SiteGrid
contentMain={
<AnimateItems
animateOnFirstLoadOnly
type={animate ? 'bottom' : 'none'}
distanceOffset={10}
items={showNav
? [<div
key="nav"
className={clsx(
'flex items-center',
'w-full min-h-[4rem]',
)}>
<div className="flex-grow">
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={showAdmin}
/>
</div>
<div className="hidden xs:block text-right text-balance">
{renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
</div>
</div>]
: []}
/>
}
/>
);
};

View File

@ -4,6 +4,9 @@ import { AnimationConfig } from '@/components/AnimateItems';
export interface AppStateContext {
previousPathname?: string
hasLoaded?: boolean
userEmail?: string
setUserEmail?: Dispatch<SetStateAction<string | undefined>>
isUserSignedIn?: boolean
setHasLoaded?: Dispatch<SetStateAction<boolean>>
nextPhotoAnimation?: AnimationConfig
setNextPhotoAnimation?: Dispatch<SetStateAction<AnimationConfig | undefined>>

View File

@ -4,6 +4,7 @@ import { useState, useEffect, ReactNode } from 'react';
import { AppStateContext } from './AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames';
import { getCurrentUser } from '@/auth/actions';
export default function AppStateProvider({
children,
@ -14,6 +15,8 @@ export default function AppStateProvider({
const [hasLoaded, setHasLoaded] =
useState(false);
const [userEmail, setUserEmail] =
useState<string>();
const [nextPhotoAnimation, setNextPhotoAnimation] =
useState<AnimationConfig>();
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
@ -25,6 +28,7 @@ export default function AppStateProvider({
useEffect(() => {
setHasLoaded?.(true);
getCurrentUser().then(user => setUserEmail?.(user?.email ?? undefined));
}, [setHasLoaded]);
return (
@ -33,6 +37,9 @@ export default function AppStateProvider({
previousPathname,
hasLoaded,
setHasLoaded,
isUserSignedIn: userEmail !== undefined,
userEmail,
setUserEmail,
nextPhotoAnimation,
setNextPhotoAnimation,
shouldRespondToKeyboardCommands,