Refine photo select/deselect, admin app menu

This commit is contained in:
Sam Becker 2024-07-14 18:31:35 -05:00
parent 1088229885
commit 6eecb553f4
11 changed files with 120 additions and 54 deletions

View File

@ -0,0 +1,52 @@
'use client';
import MoreMenu from '@/components/more/MoreMenu';
import { GRID_HOMEPAGE_ENABLED } from '@/site/config';
import { PATH_ADMIN_CONFIGURATION, PATH_GRID, PATH_ROOT } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { BiCog } from 'react-icons/bi';
import { FaTimes } from 'react-icons/fa';
import { ImCheckboxUnchecked } from 'react-icons/im';
export default function AdminAppMenu() {
const {
selectedPhotoIds,
setSelectedPhotoIds,
} = useAppState();
const isSelecting = selectedPhotoIds !== undefined;
return (
<MoreMenu
items={[{
label: 'App Config',
icon: <BiCog className="text-[17px]" />,
href: PATH_ADMIN_CONFIGURATION,
}, {
label: isSelecting
? 'Exit Select'
: 'Select Multiple',
icon: isSelecting
? <FaTimes
className="translate-y-[1px]"
/>
: <ImCheckboxUnchecked
className="text-[0.75rem] translate-y-[2px]"
/>,
href: GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID,
action: () => {
if (isSelecting) {
setSelectedPhotoIds?.(undefined);
} else {
setSelectedPhotoIds?.([]);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
},
shouldPreventDefault: false,
}]}
ariaLabel="Admin Menu"
/>
);
}

View File

@ -5,32 +5,41 @@ import LoaderButton from '@/components/primitives/LoaderButton';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5';
import DeleteButton from './DeleteButton';
export default function AdminBatchEditPanel() { export default function AdminBatchEditPanel() {
const { const {
isUserSignedIn, isUserSignedIn,
selectedPhotoIds = [], selectedPhotoIds,
setSelectedPhotoIds, setSelectedPhotoIds,
} = useAppState(); } = useAppState();
return isUserSignedIn && selectedPhotoIds.length > 0 return isUserSignedIn && selectedPhotoIds !== undefined
? <SiteGrid ? <SiteGrid
className="mb-5 sticky top-0 z-10 -mt-2 pt-2" className="sticky top-0 z-10 mb-5 -mt-2 pt-2"
contentMain={<Note contentMain={<Note
color="gray" color="gray"
className={clsx( className={clsx(
'backdrop-blur-lg !border-transparent', 'backdrop-blur-lg !border-transparent',
'!text-gray-900 dark:!text-gray-100', '!text-gray-900 dark:!text-gray-100',
'!bg-gray-100/90 dark:!bg-gray-900/80' '!bg-gray-100/90 dark:!bg-gray-900/70'
)} )}
cta={<LoaderButton cta={<div className="flex gap-2">
onClick={() => setSelectedPhotoIds?.([])} <LoaderButton>
primary Tag ...
> </LoaderButton>
Clear <DeleteButton />
</LoaderButton>} <LoaderButton
icon={<IoCloseSharp size={20} className="translate-y-[-1.5px]" />}
onClick={() => setSelectedPhotoIds?.(undefined)}
/>
</div>}
> >
{selectedPhotoIds.length} photos selected {selectedPhotoIds.length}
{selectedPhotoIds.length === 1 ? ' photo' : ' photos'}
{' '}
selected
</Note>} /> </Note>} />
: null; : null;
} }

View File

@ -9,18 +9,19 @@ export default function SelectTileOverlay({
onSelectChange: () => void onSelectChange: () => void
}) { }) {
return ( return (
<> <div className={clsx(
'absolute w-full h-full cursor-pointer',
'active:bg-gray-950/40 active:dark:bg-gray-950/60',
)}>
{/* Admin Select Border */} {/* Admin Select Border */}
<div className={clsx( <div
'absolute w-full h-full pointer-events-none', className="w-full h-full"
)}> onClick={onSelectChange}
>
<div <div
className={clsx( className={clsx(
'w-full h-full', 'w-full h-full',
'border-black dark:border-white', 'border-black dark:border-white',
'transition-opacity',
!isSelected && 'opacity-0',
'group-hover:opacity-100',
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(1,0,0,0.40)_0%,rgba(255,255,255,0.00)_75%)]', 'bg-[radial-gradient(169.40%_89.55%_at_94.76%_6.29%,rgba(1,0,0,0.40)_0%,rgba(255,255,255,0.00)_75%)]',
isSelected && 'border-4', isSelected && 'border-4',
@ -32,7 +33,6 @@ export default function SelectTileOverlay({
<Checkbox <Checkbox
className={clsx( className={clsx(
'text-white', 'text-white',
!isSelected && 'opacity-0 group-hover:opacity-100',
// Required to prevent Safari jitter // Required to prevent Safari jitter
'translate-x-[0.1px]', 'translate-x-[0.1px]',
)} )}
@ -40,6 +40,6 @@ export default function SelectTileOverlay({
onChange={onSelectChange} onChange={onSelectChange}
/> />
</div> </div>
</> </div>
); );
} }

View File

@ -20,12 +20,12 @@ export default function SiteGrid({
<div <div
ref={containerRef} ref={containerRef}
className={clsx( className={clsx(
className,
'grid', 'grid',
'grid-cols-1 md:grid-cols-12', 'grid-cols-1 md:grid-cols-12',
'gap-x-4 lg:gap-x-6', 'gap-x-4 lg:gap-x-6',
'gap-y-4', 'gap-y-4',
'max-w-7xl', 'max-w-7xl',
className,
)} )}
> >
<div className={clsx( <div className={clsx(

View File

@ -1,24 +1,16 @@
import { ReactNode } from 'react'; import { ComponentProps } 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';
import MoreMenuItem from './MoreMenuItem'; import MoreMenuItem from './MoreMenuItem';
export interface MoreMenuItem {
label: ReactNode
icon?: ReactNode
href?: string
hrefDownloadName?: string
action?: () => Promise<void> | void
}
export default function MoreMenu({ export default function MoreMenu({
items, items,
className, className,
buttonClassName, buttonClassName,
ariaLabel, ariaLabel,
}: { }: {
items: MoreMenuItem[] items: ComponentProps<typeof MoreMenuItem> []
className?: string className?: string
buttonClassName?: string buttonClassName?: string
ariaLabel: string ariaLabel: string
@ -44,23 +36,17 @@ export default function MoreMenu({
<DropdownMenu.Content <DropdownMenu.Content
align="end" align="end"
className={clsx( className={clsx(
className, 'z-10',
'min-w-[8rem]', 'min-w-[8rem]',
'ml-2.5', 'ml-2.5',
'p-1 rounded-md border', 'p-1 rounded-md border',
'bg-content', 'bg-content',
'shadow-lg dark:shadow-xl', 'shadow-lg dark:shadow-xl',
className,
)} )}
> >
{items.map(({ label, icon, href, hrefDownloadName, action }) => {items.map(props =>
<MoreMenuItem <MoreMenuItem key={`${props.label}`} {...props} />
key={`${label}`}
label={label}
icon={icon}
href={href}
hrefDownloadName={hrefDownloadName}
action={action}
/>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>

View File

@ -4,7 +4,7 @@ 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, useState, useTransition } from 'react';
import LoaderButton from '../primitives/LoaderButton'; import LoaderButton from '../primitives/LoaderButton';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
export default function MoreMenuItem({ export default function MoreMenuItem({
label, label,
@ -12,15 +12,19 @@ export default function MoreMenuItem({
href, href,
hrefDownloadName, hrefDownloadName,
action, action,
shouldPreventDefault = true,
}: { }: {
label: ReactNode label: ReactNode
icon?: ReactNode icon?: ReactNode
href?: string href?: string
hrefDownloadName?: string hrefDownloadName?: string
action?: () => Promise<void> | void action?: () => Promise<void> | void
shouldPreventDefault?: boolean
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -39,8 +43,8 @@ export default function MoreMenuItem({
: 'cursor-pointer', : 'cursor-pointer',
)} )}
onClick={e => { onClick={e => {
e.preventDefault(); if (shouldPreventDefault) { e.preventDefault(); }
if (href) { if (href && href !== pathname) {
if (Boolean(hrefDownloadName)) { if (Boolean(hrefDownloadName)) {
window.open(href, '_blank'); window.open(href, '_blank');
} else { } else {

View File

@ -49,7 +49,7 @@ export default function PhotoGrid({
}) { }) {
const { const {
isUserSignedIn, isUserSignedIn,
selectedPhotoIds = [], selectedPhotoIds,
setSelectedPhotoIds, setSelectedPhotoIds,
} = useAppState(); } = useAppState();
@ -73,7 +73,7 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly} staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete} onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) =>{ items={photos.map((photo, index) =>{
const isSelected = selectedPhotoIds.includes(photo.id); const isSelected = selectedPhotoIds?.includes(photo.id) ?? false;
return <div return <div
key={photo.id} key={photo.id}
className={clsx( className={clsx(
@ -89,8 +89,8 @@ export default function PhotoGrid({
<PhotoMedium <PhotoMedium
className={clsx( className={clsx(
'flex w-full h-full', 'flex w-full h-full',
// Prevent accidental navigation when selecting // Prevent photo navigation when selecting
selectedPhotoIds.length > 0 && 'pointer-events-none', selectedPhotoIds?.length !== undefined && 'pointer-events-none',
)} )}
{...{ {...{
photo, photo,
@ -105,12 +105,12 @@ export default function PhotoGrid({
: undefined, : undefined,
}} }}
/> />
{canSelect && isUserSignedIn && {isUserSignedIn && canSelect && selectedPhotoIds !== undefined &&
<SelectTileOverlay <SelectTileOverlay
isSelected={isSelected} isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected onSelectChange={() => setSelectedPhotoIds?.(isSelected
? selectedPhotoIds.filter(id => id !== photo.id) ? (selectedPhotoIds ?? []).filter(id => id !== photo.id)
: selectedPhotoIds.concat(photo.id), : (selectedPhotoIds ?? []).concat(photo.id),
)} )}
/>} />}
</div>; </div>;

View File

@ -25,7 +25,10 @@ export default function PhotoGridPage({
}) { }) {
const { setSelectedPhotoIds } = useAppState(); const { setSelectedPhotoIds } = useAppState();
useEffect(() => () => setSelectedPhotoIds?.([]), [setSelectedPhotoIds]); useEffect(
() => () => setSelectedPhotoIds?.(undefined),
[setSelectedPhotoIds]
);
return ( return (
<PhotoGridContainer <PhotoGridContainer

View File

@ -16,6 +16,7 @@ import {
import AnimateItems from '../components/AnimateItems'; import AnimateItems from '../components/AnimateItems';
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';
export default function Nav({ export default function Nav({
siteDomainOrTitle, siteDomainOrTitle,
@ -76,6 +77,17 @@ export default function Nav({
: []} : []}
/> />
} }
contentSide={isUserSignedIn && !isPathAdmin(pathname)
? <div
className={clsx(
'flex items-center translate-x-[-6px]',
'w-full min-h-[4rem]',
)}
>
<AdminAppMenu />
</div>
: undefined}
sideHiddenOnMobile
/> />
); );
}; };

View File

@ -23,7 +23,7 @@ export interface AppStateContext {
registerAdminUpdate?: () => void registerAdminUpdate?: () => void
hiddenPhotosCount?: number hiddenPhotosCount?: number
selectedPhotoIds?: string[] selectedPhotoIds?: string[]
setSelectedPhotoIds?: Dispatch<SetStateAction<string[]>> setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
// DEBUG // DEBUG
arePhotosMatted?: boolean arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>> setArePhotosMatted?: Dispatch<SetStateAction<boolean>>

View File

@ -35,7 +35,7 @@ export default function AppStateProvider({
const [hiddenPhotosCount, setHiddenPhotosCount] = const [hiddenPhotosCount, setHiddenPhotosCount] =
useState(0); useState(0);
const [selectedPhotoIds, setSelectedPhotoIds] = const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[]>([]); useState<string[] | undefined>();
// DEBUG // DEBUG
const [arePhotosMatted, setArePhotosMatted] = const [arePhotosMatted, setArePhotosMatted] =
useState(MATTE_PHOTOS); useState(MATTE_PHOTOS);