Create multi-item admin menu

This commit is contained in:
Sam Becker 2025-02-25 09:13:43 -06:00
parent bc24d42864
commit 9f483bcf21
8 changed files with 124 additions and 50 deletions

View File

@ -4,15 +4,27 @@ import MoreMenu from '@/components/more/MoreMenu';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { ImCheckboxUnchecked } from 'react-icons/im';
import { IoCloseSharp } from 'react-icons/io5';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
import { LuCog } from 'react-icons/lu';
import { clsx } from 'clsx/lite';
import { TbPhoto } from 'react-icons/tb';
import { FiTag } from 'react-icons/fi';
import { BiLockAlt } from 'react-icons/bi';
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
export default function AdminAppMenu() {
export default function AdminAppMenu({
className,
buttonClassName,
}: {
className?: string
buttonClassName?: string
}) {
const {
selectedPhotoIds,
setSelectedPhotoIds,
@ -22,28 +34,51 @@ export default function AdminAppMenu() {
return (
<MoreMenu
header="Admin menu"
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
align="start"
className={clsx(
'border-medium',
className,
)}
buttonClassName={clsx(
'rounded-none focus:outline-none',
buttonClassName,
)}
items={[{
label: 'Manage Photos',
icon: <TbPhoto
size={16}
className="translate-x-[1px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_PHOTOS,
}, {
label: 'Manage Tags',
icon: <FiTag
size={16}
className="translate-x-[1.5px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_TAGS,
}, {
label: 'Insights',
icon: <span className="scale-90 translate-y-[-2px]">
<AdminAppInsightsIcon />
</span>,
icon: <AdminAppInsightsIcon className="translate-y-[-4px]" />,
href: PATH_ADMIN_INSIGHTS,
}, {
label: 'Configuration',
icon: <LuCog
className="text-[16px] translate-x-[0.5px]"
className="text-[17px] translate-x-[0.5px] translate-y-[0.5px]"
/>,
href: PATH_ADMIN_CONFIGURATION,
}, {
label: isSelecting
? 'Exit Select'
: 'Select',
: 'Select Photos',
icon: isSelecting
? <IoCloseSharp
className="text-[18px] translate-y-[-0.5px]"
/>
: <ImCheckboxUnchecked
className="text-[0.75rem] translate-x-[0.5px]"
className="text-[0.75rem] translate-x-[1px]"
/>,
href: PATH_GRID_INFERRED,
action: () => {

View File

@ -47,7 +47,10 @@ export default function AdminPhotoMenuClient({
const items = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
icon: <FaRegEdit size={14} />,
icon: <FaRegEdit
size={15}
className="translate-x-[0.5px] translate-y-[-0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
}];
if (includeFavorite) {

View File

@ -2,13 +2,20 @@ import { useAppState } from '@/state/AppState';
import clsx from 'clsx/lite';
import { LuLightbulb } from 'react-icons/lu';
import { FaCircle } from 'react-icons/fa6';
export default function AdminAppInsightsIcon() {
export default function AdminAppInsightsIcon({
className,
}: {
className?: string
}) {
const {
insightIndicatorStatus,
} = useAppState();
return (
<span className="inline-flex relative">
<span className={clsx(
'inline-flex relative',
className,
)}>
<LuLightbulb
size={18}
className="translate-y-[3.5px]"

View File

@ -14,13 +14,11 @@ import {
isPathSignIn,
} from '@/app/paths';
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
import {
GRID_HOMEPAGE_ENABLED,
HAS_DEFINED_SITE_DESCRIPTION,
SITE_DESCRIPTION,
} from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
const NAV_HEIGHT_CLASS = HAS_DEFINED_SITE_DESCRIPTION
? 'min-h-[4rem] sm:min-h-[5rem]'
@ -33,8 +31,6 @@ export default function Nav({
}) {
const pathname = usePathname();
const { isUserSignedIn } = useAppState();
const showNav = !isPathSignIn(pathname);
const renderLink = (
@ -73,7 +69,6 @@ export default function Nav({
)}>
<ViewSwitcher
currentSelection={switcherSelectionForPath()}
showAdmin={isUserSignedIn}
/>
<div className={clsx(
'grow text-right min-w-0',
@ -98,16 +93,6 @@ export default function Nav({
: []}
/>
}
contentSide={isUserSignedIn && !isPathAdmin(pathname)
? <div
className={clsx(
'flex items-center translate-x-[-6px] w-full',
NAV_HEIGHT_CLASS,
)}
>
<AdminAppMenu />
</div>
: undefined}
sideHiddenOnMobile
/>
);

View File

@ -3,25 +3,23 @@ import SwitcherItem from '@/components/SwitcherItem';
import IconFeed from '@/app/IconFeed';
import IconGrid from '@/app/IconGrid';
import {
PATH_ADMIN_PHOTOS,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { BiLockAlt } from 'react-icons/bi';
import IconSearch from './IconSearch';
import { useAppState } from '@/state/AppState';
import { GRID_HOMEPAGE_ENABLED } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
import { clsx } from 'clsx/lite';
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
export default function ViewSwitcher({
currentSelection,
showAdmin,
}: {
currentSelection?: SwitcherSelection
showAdmin?: boolean
}) {
const { setIsCommandKOpen } = useAppState();
const { setIsCommandKOpen, isUserSignedIn } = useAppState();
const renderItemFeed = () =>
<SwitcherItem
@ -44,11 +42,17 @@ export default function ViewSwitcher({
<Switcher>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid() : renderItemFeed()}
{GRID_HOMEPAGE_ENABLED ? renderItemFeed() : renderItemGrid()}
{showAdmin &&
<SwitcherItem
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
href={PATH_ADMIN_PHOTOS}
active={currentSelection === 'admin'}
{isUserSignedIn &&
<AdminAppMenu
className="mt-3 ml-[-84px]"
buttonClassName={clsx(
'w-[40px] h-[28px]',
'flex items-center justify-center',
'active:bg-transparent',
currentSelection === 'admin'
? 'text-black dark:text-white'
: 'text-gray-400 dark:text-gray-600',
)}
/>}
</Switcher>
<Switcher type="borderless">

View File

@ -11,10 +11,10 @@ export default function Switcher({
return (
<div className={clsx(
'flex divide-x overflow-hidden',
'divide-gray-300 dark:divide-gray-800',
'divide-medium',
'border rounded-md',
type === 'regular'
? 'border-gray-300 dark:border-gray-800'
? 'border-medium'
: 'border-transparent',
type === 'regular' && 'shadow-xs',
)}>

View File

@ -1,4 +1,4 @@
import { ComponentProps } from 'react';
import { ComponentProps, ReactNode, useCallback, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi';
@ -6,38 +6,50 @@ import MoreMenuItem from './MoreMenuItem';
export default function MoreMenu({
items,
icon,
header,
className,
buttonClassName,
ariaLabel,
align = 'end',
...props
}: {
items: ComponentProps<typeof MoreMenuItem> []
icon?: ReactNode
header?: ReactNode
className?: string
buttonClassName?: string
ariaLabel: string
}){
} & ComponentProps<typeof DropdownMenu.Content>){
const [isOpen, setIsOpen] = useState(false);
const dismissMenu = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
return (
<DropdownMenu.Root>
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger asChild>
<button
className={clsx(
buttonClassName,
'p-1 min-h-0 border-none shadow-none hover:outline-hidden',
'hover:bg-gray-100 active:bg-gray-100',
'dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
'text-dim',
buttonClassName,
)}
aria-label={ariaLabel}
>
<FiMoreHorizontal size={18} />
{icon ?? <FiMoreHorizontal size={18} />}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
{...props}
align={align}
className={clsx(
'z-10',
'min-w-[8rem]',
'ml-2.5',
'component-surface',
'p-1',
'shadow-lg dark:shadow-xl',
@ -46,8 +58,18 @@ export default function MoreMenu({
className,
)}
>
{header && <div className={clsx(
'px-2 py-1.5 text-medium uppercase',
'text-sm',
)}>
{header}
</div>}
{items.map(props =>
<MoreMenuItem key={`${props.label}`} {...props} />,
<MoreMenuItem
key={`${props.label}`}
{...props}
dismissMenu={dismissMenu}
/>,
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

@ -2,7 +2,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { clsx } from 'clsx/lite';
import { ReactNode, useState, useTransition } from 'react';
import { ReactNode, useEffect, useState, useTransition } from 'react';
import LoaderButton from '../primitives/LoaderButton';
import { usePathname, useRouter } from 'next/navigation';
import { downloadFileFromBrowser } from '@/utility/url';
@ -14,6 +14,7 @@ export default function MoreMenuItem({
hrefDownloadName,
className,
action,
dismissMenu,
shouldPreventDefault = true,
}: {
label: ReactNode
@ -22,6 +23,7 @@ export default function MoreMenuItem({
hrefDownloadName?: string
className?: string
action?: () => Promise<void> | void
dismissMenu?: () => void
shouldPreventDefault?: boolean
}) {
const router = useRouter();
@ -30,8 +32,17 @@ export default function MoreMenuItem({
const [isPending, startTransition] = useTransition();
const [transitionDidStart, setTransitionDidStart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (transitionDidStart && !isPending) {
dismissMenu?.();
setTransitionDidStart(false);
}
}, [isPending, dismissMenu, transitionDidStart]);
return (
<DropdownMenu.Item
disabled={isLoading}
@ -47,21 +58,28 @@ export default function MoreMenuItem({
: 'cursor-pointer',
className,
)}
onClick={async e => {
onSelect={async e => {
if (shouldPreventDefault) { e.preventDefault(); }
if (action) {
const result = action();
if (result instanceof Promise) {
setIsLoading(true);
await result.finally(() => setIsLoading(false));
await result.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
}
}
if (href && href !== pathname) {
if (hrefDownloadName) {
setIsLoading(true);
downloadFileFromBrowser(href, hrefDownloadName)
.finally(() => setIsLoading(false));
.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
} else {
setTransitionDidStart(true);
startTransition(() => router.push(href));
}
}