Refine photo select/deselect, admin app menu
This commit is contained in:
parent
1088229885
commit
6eecb553f4
52
src/admin/AdminAppMenu.tsx
Normal file
52
src/admin/AdminAppMenu.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -25,7 +25,10 @@ export default function PhotoGridPage({
|
||||
}) {
|
||||
const { setSelectedPhotoIds } = useAppState();
|
||||
|
||||
useEffect(() => () => setSelectedPhotoIds?.([]), [setSelectedPhotoIds]);
|
||||
useEffect(
|
||||
() => () => setSelectedPhotoIds?.(undefined),
|
||||
[setSelectedPhotoIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<PhotoGridContainer
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user