Create multi-item admin menu
This commit is contained in:
parent
bc24d42864
commit
9f483bcf21
@ -4,15 +4,27 @@ import MoreMenu from '@/components/more/MoreMenu';
|
|||||||
import {
|
import {
|
||||||
PATH_ADMIN_CONFIGURATION,
|
PATH_ADMIN_CONFIGURATION,
|
||||||
PATH_ADMIN_INSIGHTS,
|
PATH_ADMIN_INSIGHTS,
|
||||||
|
PATH_ADMIN_PHOTOS,
|
||||||
|
PATH_ADMIN_TAGS,
|
||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { ImCheckboxUnchecked } from 'react-icons/im';
|
import { ImCheckboxUnchecked } from 'react-icons/im';
|
||||||
import { IoCloseSharp } from 'react-icons/io5';
|
import { IoCloseSharp } from 'react-icons/io5';
|
||||||
import AdminAppInsightsIcon from './insights/AdminAppInsightsIcon';
|
|
||||||
import { LuCog } from 'react-icons/lu';
|
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 {
|
const {
|
||||||
selectedPhotoIds,
|
selectedPhotoIds,
|
||||||
setSelectedPhotoIds,
|
setSelectedPhotoIds,
|
||||||
@ -22,28 +34,51 @@ export default function AdminAppMenu() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MoreMenu
|
<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={[{
|
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',
|
label: 'Insights',
|
||||||
icon: <span className="scale-90 translate-y-[-2px]">
|
icon: <AdminAppInsightsIcon className="translate-y-[-4px]" />,
|
||||||
<AdminAppInsightsIcon />
|
|
||||||
</span>,
|
|
||||||
href: PATH_ADMIN_INSIGHTS,
|
href: PATH_ADMIN_INSIGHTS,
|
||||||
}, {
|
}, {
|
||||||
label: 'Configuration',
|
label: 'Configuration',
|
||||||
icon: <LuCog
|
icon: <LuCog
|
||||||
className="text-[16px] translate-x-[0.5px]"
|
className="text-[17px] translate-x-[0.5px] translate-y-[0.5px]"
|
||||||
/>,
|
/>,
|
||||||
href: PATH_ADMIN_CONFIGURATION,
|
href: PATH_ADMIN_CONFIGURATION,
|
||||||
}, {
|
}, {
|
||||||
label: isSelecting
|
label: isSelecting
|
||||||
? 'Exit Select'
|
? 'Exit Select'
|
||||||
: 'Select',
|
: 'Select Photos',
|
||||||
icon: isSelecting
|
icon: isSelecting
|
||||||
? <IoCloseSharp
|
? <IoCloseSharp
|
||||||
className="text-[18px] translate-y-[-0.5px]"
|
className="text-[18px] translate-y-[-0.5px]"
|
||||||
/>
|
/>
|
||||||
: <ImCheckboxUnchecked
|
: <ImCheckboxUnchecked
|
||||||
className="text-[0.75rem] translate-x-[0.5px]"
|
className="text-[0.75rem] translate-x-[1px]"
|
||||||
/>,
|
/>,
|
||||||
href: PATH_GRID_INFERRED,
|
href: PATH_GRID_INFERRED,
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|||||||
@ -47,7 +47,10 @@ export default function AdminPhotoMenuClient({
|
|||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const items: ComponentProps<typeof MoreMenuItem>[] = [{
|
const items: ComponentProps<typeof MoreMenuItem>[] = [{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <FaRegEdit size={14} />,
|
icon: <FaRegEdit
|
||||||
|
size={15}
|
||||||
|
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
||||||
|
/>,
|
||||||
href: pathForAdminPhotoEdit(photo.id),
|
href: pathForAdminPhotoEdit(photo.id),
|
||||||
}];
|
}];
|
||||||
if (includeFavorite) {
|
if (includeFavorite) {
|
||||||
|
|||||||
@ -2,13 +2,20 @@ import { useAppState } from '@/state/AppState';
|
|||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { LuLightbulb } from 'react-icons/lu';
|
import { LuLightbulb } from 'react-icons/lu';
|
||||||
import { FaCircle } from 'react-icons/fa6';
|
import { FaCircle } from 'react-icons/fa6';
|
||||||
export default function AdminAppInsightsIcon() {
|
export default function AdminAppInsightsIcon({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
insightIndicatorStatus,
|
insightIndicatorStatus,
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex relative">
|
<span className={clsx(
|
||||||
|
'inline-flex relative',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
<LuLightbulb
|
<LuLightbulb
|
||||||
size={18}
|
size={18}
|
||||||
className="translate-y-[3.5px]"
|
className="translate-y-[3.5px]"
|
||||||
|
|||||||
@ -14,13 +14,11 @@ import {
|
|||||||
isPathSignIn,
|
isPathSignIn,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import AnimateItems from '../components/AnimateItems';
|
import AnimateItems from '../components/AnimateItems';
|
||||||
import { useAppState } from '@/state/AppState';
|
|
||||||
import {
|
import {
|
||||||
GRID_HOMEPAGE_ENABLED,
|
GRID_HOMEPAGE_ENABLED,
|
||||||
HAS_DEFINED_SITE_DESCRIPTION,
|
HAS_DEFINED_SITE_DESCRIPTION,
|
||||||
SITE_DESCRIPTION,
|
SITE_DESCRIPTION,
|
||||||
} from './config';
|
} from './config';
|
||||||
import AdminAppMenu from '@/admin/AdminAppMenu';
|
|
||||||
|
|
||||||
const NAV_HEIGHT_CLASS = HAS_DEFINED_SITE_DESCRIPTION
|
const NAV_HEIGHT_CLASS = HAS_DEFINED_SITE_DESCRIPTION
|
||||||
? 'min-h-[4rem] sm:min-h-[5rem]'
|
? 'min-h-[4rem] sm:min-h-[5rem]'
|
||||||
@ -33,8 +31,6 @@ export default function Nav({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const { isUserSignedIn } = useAppState();
|
|
||||||
|
|
||||||
const showNav = !isPathSignIn(pathname);
|
const showNav = !isPathSignIn(pathname);
|
||||||
|
|
||||||
const renderLink = (
|
const renderLink = (
|
||||||
@ -73,7 +69,6 @@ export default function Nav({
|
|||||||
)}>
|
)}>
|
||||||
<ViewSwitcher
|
<ViewSwitcher
|
||||||
currentSelection={switcherSelectionForPath()}
|
currentSelection={switcherSelectionForPath()}
|
||||||
showAdmin={isUserSignedIn}
|
|
||||||
/>
|
/>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grow text-right min-w-0',
|
'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
|
sideHiddenOnMobile
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,25 +3,23 @@ import SwitcherItem from '@/components/SwitcherItem';
|
|||||||
import IconFeed from '@/app/IconFeed';
|
import IconFeed from '@/app/IconFeed';
|
||||||
import IconGrid from '@/app/IconGrid';
|
import IconGrid from '@/app/IconGrid';
|
||||||
import {
|
import {
|
||||||
PATH_ADMIN_PHOTOS,
|
|
||||||
PATH_FEED_INFERRED,
|
PATH_FEED_INFERRED,
|
||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import { BiLockAlt } from 'react-icons/bi';
|
|
||||||
import IconSearch from './IconSearch';
|
import IconSearch from './IconSearch';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { GRID_HOMEPAGE_ENABLED } from './config';
|
import { GRID_HOMEPAGE_ENABLED } from './config';
|
||||||
|
import AdminAppMenu from '@/admin/AdminAppMenu';
|
||||||
|
import { clsx } from 'clsx/lite';
|
||||||
|
|
||||||
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
|
export type SwitcherSelection = 'feed' | 'grid' | 'admin';
|
||||||
|
|
||||||
export default function ViewSwitcher({
|
export default function ViewSwitcher({
|
||||||
currentSelection,
|
currentSelection,
|
||||||
showAdmin,
|
|
||||||
}: {
|
}: {
|
||||||
currentSelection?: SwitcherSelection
|
currentSelection?: SwitcherSelection
|
||||||
showAdmin?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { setIsCommandKOpen } = useAppState();
|
const { setIsCommandKOpen, isUserSignedIn } = useAppState();
|
||||||
|
|
||||||
const renderItemFeed = () =>
|
const renderItemFeed = () =>
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
@ -44,11 +42,17 @@ export default function ViewSwitcher({
|
|||||||
<Switcher>
|
<Switcher>
|
||||||
{GRID_HOMEPAGE_ENABLED ? renderItemGrid() : renderItemFeed()}
|
{GRID_HOMEPAGE_ENABLED ? renderItemGrid() : renderItemFeed()}
|
||||||
{GRID_HOMEPAGE_ENABLED ? renderItemFeed() : renderItemGrid()}
|
{GRID_HOMEPAGE_ENABLED ? renderItemFeed() : renderItemGrid()}
|
||||||
{showAdmin &&
|
{isUserSignedIn &&
|
||||||
<SwitcherItem
|
<AdminAppMenu
|
||||||
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
|
className="mt-3 ml-[-84px]"
|
||||||
href={PATH_ADMIN_PHOTOS}
|
buttonClassName={clsx(
|
||||||
active={currentSelection === 'admin'}
|
'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>
|
||||||
<Switcher type="borderless">
|
<Switcher type="borderless">
|
||||||
|
|||||||
@ -11,10 +11,10 @@ export default function Switcher({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex divide-x overflow-hidden',
|
'flex divide-x overflow-hidden',
|
||||||
'divide-gray-300 dark:divide-gray-800',
|
'divide-medium',
|
||||||
'border rounded-md',
|
'border rounded-md',
|
||||||
type === 'regular'
|
type === 'regular'
|
||||||
? 'border-gray-300 dark:border-gray-800'
|
? 'border-medium'
|
||||||
: 'border-transparent',
|
: 'border-transparent',
|
||||||
type === 'regular' && 'shadow-xs',
|
type === 'regular' && 'shadow-xs',
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@ -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 * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||||
@ -6,38 +6,50 @@ import MoreMenuItem from './MoreMenuItem';
|
|||||||
|
|
||||||
export default function MoreMenu({
|
export default function MoreMenu({
|
||||||
items,
|
items,
|
||||||
|
icon,
|
||||||
|
header,
|
||||||
className,
|
className,
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
align = 'end',
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
items: ComponentProps<typeof MoreMenuItem> []
|
items: ComponentProps<typeof MoreMenuItem> []
|
||||||
|
icon?: ReactNode
|
||||||
|
header?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
}){
|
} & ComponentProps<typeof DropdownMenu.Content>){
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const dismissMenu = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [setIsOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
buttonClassName,
|
|
||||||
'p-1 min-h-0 border-none shadow-none hover:outline-hidden',
|
'p-1 min-h-0 border-none shadow-none hover:outline-hidden',
|
||||||
'hover:bg-gray-100 active:bg-gray-100',
|
'hover:bg-gray-100 active:bg-gray-100',
|
||||||
'dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
|
'dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
|
||||||
'text-dim',
|
'text-dim',
|
||||||
|
buttonClassName,
|
||||||
)}
|
)}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<FiMoreHorizontal size={18} />
|
{icon ?? <FiMoreHorizontal size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
align="end"
|
{...props}
|
||||||
|
align={align}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'z-10',
|
'z-10',
|
||||||
'min-w-[8rem]',
|
'min-w-[8rem]',
|
||||||
'ml-2.5',
|
|
||||||
'component-surface',
|
'component-surface',
|
||||||
'p-1',
|
'p-1',
|
||||||
'shadow-lg dark:shadow-xl',
|
'shadow-lg dark:shadow-xl',
|
||||||
@ -46,8 +58,18 @@ export default function MoreMenu({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{header && <div className={clsx(
|
||||||
|
'px-2 py-1.5 text-medium uppercase',
|
||||||
|
'text-sm',
|
||||||
|
)}>
|
||||||
|
{header}
|
||||||
|
</div>}
|
||||||
{items.map(props =>
|
{items.map(props =>
|
||||||
<MoreMenuItem key={`${props.label}`} {...props} />,
|
<MoreMenuItem
|
||||||
|
key={`${props.label}`}
|
||||||
|
{...props}
|
||||||
|
dismissMenu={dismissMenu}
|
||||||
|
/>,
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { ReactNode, useState, useTransition } from 'react';
|
import { ReactNode, useEffect, useState, useTransition } from 'react';
|
||||||
import LoaderButton from '../primitives/LoaderButton';
|
import LoaderButton from '../primitives/LoaderButton';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { downloadFileFromBrowser } from '@/utility/url';
|
import { downloadFileFromBrowser } from '@/utility/url';
|
||||||
@ -14,6 +14,7 @@ export default function MoreMenuItem({
|
|||||||
hrefDownloadName,
|
hrefDownloadName,
|
||||||
className,
|
className,
|
||||||
action,
|
action,
|
||||||
|
dismissMenu,
|
||||||
shouldPreventDefault = true,
|
shouldPreventDefault = true,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
@ -22,6 +23,7 @@ export default function MoreMenuItem({
|
|||||||
hrefDownloadName?: string
|
hrefDownloadName?: string
|
||||||
className?: string
|
className?: string
|
||||||
action?: () => Promise<void> | void
|
action?: () => Promise<void> | void
|
||||||
|
dismissMenu?: () => void
|
||||||
shouldPreventDefault?: boolean
|
shouldPreventDefault?: boolean
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -30,8 +32,17 @@ export default function MoreMenuItem({
|
|||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [transitionDidStart, setTransitionDidStart] = useState(false);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transitionDidStart && !isPending) {
|
||||||
|
dismissMenu?.();
|
||||||
|
setTransitionDidStart(false);
|
||||||
|
}
|
||||||
|
}, [isPending, dismissMenu, transitionDidStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@ -47,21 +58,28 @@ export default function MoreMenuItem({
|
|||||||
: 'cursor-pointer',
|
: 'cursor-pointer',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={async e => {
|
onSelect={async e => {
|
||||||
if (shouldPreventDefault) { e.preventDefault(); }
|
if (shouldPreventDefault) { e.preventDefault(); }
|
||||||
if (action) {
|
if (action) {
|
||||||
const result = action();
|
const result = action();
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await result.finally(() => setIsLoading(false));
|
await result.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
dismissMenu?.();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (href && href !== pathname) {
|
if (href && href !== pathname) {
|
||||||
if (hrefDownloadName) {
|
if (hrefDownloadName) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
downloadFileFromBrowser(href, hrefDownloadName)
|
downloadFileFromBrowser(href, hrefDownloadName)
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
dismissMenu?.();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setTransitionDidStart(true);
|
||||||
startTransition(() => router.push(href));
|
startTransition(() => router.push(href));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user