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 { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5';
import DeleteButton from './DeleteButton';
export default function AdminBatchEditPanel() {
const {
isUserSignedIn,
selectedPhotoIds = [],
selectedPhotoIds,
setSelectedPhotoIds,
} = useAppState();
return isUserSignedIn && selectedPhotoIds.length > 0
return isUserSignedIn && selectedPhotoIds !== undefined
? <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
color="gray"
className={clsx(
'backdrop-blur-lg !border-transparent',
'!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
onClick={() => setSelectedPhotoIds?.([])}
primary
>
Clear
</LoaderButton>}
cta={<div className="flex gap-2">
<LoaderButton>
Tag ...
</LoaderButton>
<DeleteButton />
<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>} />
: null;
}

View File

@ -9,18 +9,19 @@ export default function SelectTileOverlay({
onSelectChange: () => void
}) {
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 */}
<div className={clsx(
'absolute w-full h-full pointer-events-none',
)}>
<div
className="w-full h-full"
onClick={onSelectChange}
>
<div
className={clsx(
'w-full h-full',
'border-black dark:border-white',
'transition-opacity',
!isSelected && 'opacity-0',
'group-hover:opacity-100',
// 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%)]',
isSelected && 'border-4',
@ -32,7 +33,6 @@ export default function SelectTileOverlay({
<Checkbox
className={clsx(
'text-white',
!isSelected && 'opacity-0 group-hover:opacity-100',
// Required to prevent Safari jitter
'translate-x-[0.1px]',
)}
@ -40,6 +40,6 @@ export default function SelectTileOverlay({
onChange={onSelectChange}
/>
</div>
</>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import {
import AnimateItems from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
import { GRID_HOMEPAGE_ENABLED } from './config';
import AdminAppMenu from '@/admin/AdminAppMenu';
export default function Nav({
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
hiddenPhotosCount?: number
selectedPhotoIds?: string[]
setSelectedPhotoIds?: Dispatch<SetStateAction<string[]>>
setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
// DEBUG
arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>

View File

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