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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user