Merge pull request #124 from sambecker/batch-edit

Allow admins to batch edit photos
This commit is contained in:
Sam Becker 2024-07-21 12:22:59 -05:00 committed by GitHub
commit 03e855ab40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 793 additions and 184 deletions

View File

@ -215,6 +215,9 @@ FAQ
#### How do I receive template updates?
> For forked repos, click "Code," then "Update branch" from the main repo page. If you originally cloned the code, you can [create a fork](https://github.com/sambecker/exif-photo-blog/fork) from GitHub, then update your Git connection from your Vercel project settings. Once you've done this, you may need to go to your project deployments page, click •••, select "Create deployment," and choose `main`.
#### How do I edit multiple photos?
> On desktop, select ••• menu in the top right next to site title and choose, "Select Multiple." On mobile, "Select Multiple Photos" can be accessed from the search menu. From there, you can perform bulk tag, favorite, and delete actions.
#### Why don't my photo changes show up immediately?
> This template statically optimizes core views such as `/` and `/grid` to minimize visitor load times. Consequently, when photos are added, edited, or removed, it might take several minutes for those changes to propagate. If it seems like a change is not taking effect, try navigating to `/admin/configuration` and clicking "Clear Cache."

View File

@ -5,11 +5,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import Container from '@/components/Container';
import { addAllUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import {
Tags,
convertTagsForForm,
getValidationMessageForTags,
} from '@/tag';
import { Tags } from '@/tag';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
@ -22,6 +18,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle, BiImageAdd } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset';
const UPLOAD_BATCH_SIZE = 4;
@ -38,8 +35,6 @@ export default function AdminAddAllUploads({
setIsAdding: (isAdding: boolean) => void
setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>>
}) {
const divRef = useRef<HTMLDivElement>(null);
const [buttonText, setButtonText] = useState('Add All Uploads');
const [showTags, setShowTags] = useState(false);
const [tags, setTags] = useState('');
@ -121,36 +116,20 @@ export default function AdminAddAllUploads({
label="Apply tags"
type="checkbox"
value={showTags ? 'true' : 'false'}
onChange={value => {
setShowTags(value === 'true');
if (value === 'true') {
setTimeout(() =>
divRef.current?.querySelectorAll('input')[0]?.focus()
, 100);
}
}}
onChange={value => setShowTags(value === 'true')}
readOnly={isAdding}
/>
</div>
<div
ref={divRef}
className={showTags && !actionErrorMessage ? undefined : 'hidden'}
>
<FieldSetWithStatus
id="tags"
label="Optional Tags"
tagOptions={convertTagsForForm(uniqueTags)}
value={tags}
onChange={tags => {
setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}}
{showTags && !actionErrorMessage &&
<PhotoTagFieldset
tags={tags}
tagOptions={uniqueTags}
onChange={setTags}
onError={setTagErrorMessage}
readOnly={isAdding}
error={tagErrorMessage}
required={false}
openOnLoad
hideLabel
/>
</div>
/>}
<div className="space-y-2">
<ProgressButton
primary

View File

@ -0,0 +1,51 @@
'use client';
import MoreMenu from '@/components/more/MoreMenu';
import { PATH_ADMIN_CONFIGURATION, PATH_GRID_INFERRED } from '@/site/paths';
import { useAppState } from '@/state/AppState';
import { BiCog } from 'react-icons/bi';
import { ImCheckboxUnchecked } from 'react-icons/im';
import { IoCloseSharp } from 'react-icons/io5';
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
? <IoCloseSharp
className="text-[18px] translate-y-[-0.5px]"
/>
: <ImCheckboxUnchecked
className="text-[0.75rem]"
/>,
href: PATH_GRID_INFERRED,
action: () => {
if (isSelecting) {
setSelectedPhotoIds?.(undefined);
} else {
setSelectedPhotoIds?.([]);
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
},
shouldPreventDefault: false,
}]}
ariaLabel="Admin Menu"
/>
);
}

View File

@ -0,0 +1,9 @@
import { getUniqueTagsCached } from '@/photo/cache';
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
export default async function AdminBatchEditPanel() {
const uniqueTags = await getUniqueTagsCached();
return (
<AdminBatchEditPanelClient {...{ uniqueTags }} />
);
}

View File

@ -0,0 +1,178 @@
'use client';
import Note from '@/components/Note';
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 { useState } from 'react';
import { TAG_FAVS, Tags } from '@/tag';
import { usePathname } from 'next/navigation';
import { PATH_GRID_INFERRED } from '@/site/paths';
import PhotoTagFieldset from './PhotoTagFieldset';
import { tagMultiplePhotosAction } from '@/photo/actions';
import { toastSuccess } from '@/toast';
import DeletePhotosButton from './DeletePhotosButton';
import { photoQuantityText } from '@/photo';
import { FaArrowDown, FaRegStar } from 'react-icons/fa6';
export default function AdminBatchEditPanelClient({
uniqueTags,
}: {
uniqueTags: Tags
}) {
const pathname = usePathname();
const {
isUserSignedIn,
selectedPhotoIds,
setSelectedPhotoIds,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
} = useAppState();
const [tags, setTags] = useState<string>();
const [tagErrorMessage, setTagErrorMessage] = useState('');
const isInTagMode = tags !== undefined;
const resetForm = () => {
setSelectedPhotoIds?.(undefined);
setTags(undefined);
setTagErrorMessage('');
};
const photosText = photoQuantityText(
selectedPhotoIds?.length ?? 0,
false,
false,
);
const renderPhotoCTA = () => selectedPhotoIds?.length === 0
? <><FaArrowDown /> Select photos below</>
: <>{photosText} selected</>;
const renderActions = () => isInTagMode
? <>
<LoaderButton
className="min-h-[2.5rem]"
onClick={() => {
setTags(undefined);
setTagErrorMessage('');
}}
disabled={isPerformingSelectEdit}
>
Cancel
</LoaderButton>
<LoaderButton
className="min-h-[2.5rem]"
// eslint-disable-next-line max-len
confirmText={`Are you sure you want to apply tags to ${photosText}? This action cannot be undone.`}
onClick={() => {
setIsPerformingSelectEdit?.(true);
tagMultiplePhotosAction(
tags,
selectedPhotoIds ?? [],
)
.then(() => {
toastSuccess(`${photosText} tagged`);
resetForm();
})
.finally(() => setIsPerformingSelectEdit?.(false));
}}
disabled={
!tags ||
Boolean(tagErrorMessage) ||
(selectedPhotoIds?.length ?? 0) === 0 ||
isPerformingSelectEdit
}
primary
>
Apply Tags
</LoaderButton>
</>
: <>
{(selectedPhotoIds?.length ?? 0) > 0 &&
<>
<DeletePhotosButton
photoIds={selectedPhotoIds}
disabled={isPerformingSelectEdit}
onClick={() => setIsPerformingSelectEdit?.(true)}
onDelete={resetForm}
onFinish={() => setIsPerformingSelectEdit?.(false)}
/>
<LoaderButton
icon={<FaRegStar />}
disabled={isPerformingSelectEdit}
confirmText={`Are you sure you want to favorite ${photosText}?`}
onClick={() => {
setIsPerformingSelectEdit?.(true);
tagMultiplePhotosAction(
TAG_FAVS,
selectedPhotoIds ?? [],
)
.then(() => {
toastSuccess(`${photosText} favorited`);
resetForm();
})
.finally(() => setIsPerformingSelectEdit?.(false));
}}
/>
<LoaderButton
onClick={() => setTags('')}
disabled={isPerformingSelectEdit}
>
Tag ...
</LoaderButton>
</>}
<LoaderButton
icon={<IoCloseSharp size={20} className="translate-y-[0.5px]" />}
onClick={() => setSelectedPhotoIds?.(undefined)}
/>
</>;
return (
isUserSignedIn &&
pathname === PATH_GRID_INFERRED &&
selectedPhotoIds !== undefined
)
? <SiteGrid
className="sticky top-0 z-10 mb-5 -mt-2 pt-2"
contentMain={<div className="flex flex-col gap-2">
<Note
color="gray"
className={clsx(
'min-h-[3.5rem]',
'backdrop-blur-lg !border-transparent',
'!text-gray-900 dark:!text-gray-100',
'!bg-gray-100/90 dark:!bg-gray-900/70',
)}
padding={isInTagMode ? 'tight-cta-right-left' : 'tight-cta-right'}
cta={<div className="flex items-center gap-2.5">
{renderActions()}
</div>}
spaceChildren={false}
hideIcon
>
{isInTagMode
? <PhotoTagFieldset
tags={tags}
tagOptions={uniqueTags}
placeholder={`Tag ${photosText} ...`}
onChange={setTags}
onError={setTagErrorMessage}
readOnly={isPerformingSelectEdit}
openOnLoad
hideLabel
/>
: <div className="text-base flex gap-2 items-center">
{renderPhotoCTA()}
</div>}
</Note>
{tagErrorMessage &&
<div className="text-error pl-4">
{tagErrorMessage}
</div>}
</div>} />
: null;
}

View File

@ -8,10 +8,11 @@ import { Photo, deleteConfirmationTextForPhoto } from '@/photo';
import { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu, { MoreMenuItem } from '@/components/more/MoreMenu';
import MoreMenu from '@/components/more/MoreMenu';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
export default function AdminPhotoMenuClient({
photo,
@ -33,7 +34,7 @@ export default function AdminPhotoMenuClient({
const favIconClass = 'translate-x-[-1px] translate-y-[0.5px]';
const items = useMemo(() => {
const items: MoreMenuItem[] = [{
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
icon: <FaRegEdit size={14} />,
href: pathForAdminPhotoEdit(photo.id),

View File

@ -1,6 +1,6 @@
'use client';
import { Photo, deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import { Photo, titleForPhoto } from '@/photo';
import AdminTable from './AdminTable';
import { Fragment } from 'react';
import PhotoSmall from '@/photo/PhotoSmall';
@ -9,13 +9,11 @@ import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import Link from 'next/link';
import { AiOutlineEyeInvisible } from 'react-icons/ai';
import PhotoDate from '@/photo/PhotoDate';
import FormWithConfirm from '@/components/FormWithConfirm';
import EditButton from './EditButton';
import DeleteButton from './DeleteButton';
import { deletePhotoFormAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton';
export default function AdminPhotosTable({
photos,
@ -113,15 +111,10 @@ export default function AdminPhotosTable({
shouldToast
/>
{canDelete &&
<FormWithConfirm
action={deletePhotoFormAction}
confirmText={deleteConfirmationTextForPhoto(photo)}
onSubmit={() => revalidatePhoto?.(photo.id, true)}
>
<input type="hidden" name="id" value={photo.id} />
<input type="hidden" name="url" value={photo.url} />
<DeleteButton clearLocalState />
</FormWithConfirm>}
<DeletePhotoButton
photo={photo}
onDelete={() => revalidatePhoto?.(photo.id, true)}
/>}
</div>
</Fragment>)}
</AdminTable>

View File

@ -2,7 +2,7 @@ import FormWithConfirm from '@/components/FormWithConfirm';
import { deletePhotoTagGloballyAction } from '@/photo/actions';
import AdminTable from '@/admin/AdminTable';
import { Fragment } from 'react';
import DeleteButton from '@/admin/DeleteButton';
import DeleteFormButton from '@/admin/DeleteFormButton';
import { photoQuantityText } from '@/photo';
import { Tags, formatTag, sortTagsObject } from '@/tag';
import EditButton from '@/admin/EditButton';
@ -34,7 +34,7 @@ export default function AdminTagTable({
`Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`}
>
<input type="hidden" name="tag" value={tag} />
<DeleteButton clearLocalState />
<DeleteFormButton clearLocalState />
</FormWithConfirm>
</div>
</Fragment>)}

View File

@ -9,7 +9,7 @@ import { pathForAdminUploadUrl } from '@/site/paths';
import AddButton from './AddButton';
import FormWithConfirm from '@/components/FormWithConfirm';
import { deleteBlobPhotoAction } from '@/photo/actions';
import DeleteButton from './DeleteButton';
import DeleteFormButton from './DeleteFormButton';
import { UrlAddStatus } from './AdminUploadsClient';
import ResponsiveDate from '@/components/ResponsiveDate';
@ -102,7 +102,7 @@ export default function AdminUploadsTable({
value={url}
readOnly
/>
<DeleteButton />
<DeleteFormButton />
</FormWithConfirm>
</>}
</span>

View File

@ -6,7 +6,7 @@ import { clsx } from 'clsx/lite';
import { ComponentProps, useCallback } from 'react';
import { BiTrash } from 'react-icons/bi';
export default function DeleteButton (
export default function DeleteFormButton (
props: ComponentProps<typeof SubmitButtonWithStatus> & {
clearLocalState?: boolean
}

View File

@ -0,0 +1,21 @@
'use client';
import { deleteConfirmationTextForPhoto, Photo, titleForPhoto } from '@/photo';
import DeletePhotosButton from './DeletePhotosButton';
import { ComponentProps } from 'react';
export default function DeletePhotoButton({
photo,
...rest
}: {
photo: Photo
} & ComponentProps<typeof DeletePhotosButton>) {
return (
<DeletePhotosButton
{...rest}
photoIds={[photo.id]}
confirmText={deleteConfirmationTextForPhoto(photo)}
toastText={`"${titleForPhoto(photo)}" deleted`}
/>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import { photoQuantityText } from '@/photo';
import { deletePhotosAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { toastSuccess, toastWarning } from '@/toast';
import { clsx } from 'clsx/lite';
import { ComponentProps, useState } from 'react';
import { BiTrash } from 'react-icons/bi';
export default function DeletePhotosButton({
photoIds = [],
onDelete,
clearLocalState = true,
className,
onClick,
onFinish,
confirmText,
toastText,
...rest
}: {
photoIds?: string[]
onClick?: () => void
onFinish?: () => void
onDelete?: () => void
clearLocalState?: boolean
toastText?: string
} & ComponentProps<typeof LoaderButton>) {
const [isLoading, setIsLoading] = useState(false);
const photosText = photoQuantityText(photoIds.length, false, false);
const { invalidateSwr, registerAdminUpdate } = useAppState();
return (
<LoaderButton
{...rest}
title="Delete"
icon={<BiTrash size={16} />}
spinnerColor="text"
className={clsx(
'!text-red-500 dark:!text-red-600',
'active:!bg-red-100/50 active:dark:!bg-red-950/50',
'disabled:!bg-red-100/50 disabled:dark:!bg-red-950/50',
'!border-red-200 hover:!border-red-300',
'dark:!border-red-900/75 dark:hover:!border-red-900',
className,
)}
isLoading={isLoading}
// eslint-disable-next-line max-len
confirmText={confirmText ?? `Are you sure you want to delete ${photosText}? This action cannot be undone.`}
onClick={() => {
onClick?.();
setIsLoading(true);
deletePhotosAction(photoIds)
.then(() => {
toastSuccess(toastText ?? `${photosText} deleted`);
if (clearLocalState) {
invalidateSwr?.();
registerAdminUpdate?.();
}
onDelete?.();
})
.catch(() => toastWarning(`Failed to delete ${photosText}`))
.finally(() => {
setIsLoading(false);
onFinish?.();
});
}}
/>
);
}

View File

@ -0,0 +1,58 @@
'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { ComponentProps, useEffect, useRef, useState } from 'react';
export default function PhotoTagFieldset(props: {
tags: string
tagOptions?: Tags
onChange: (tags: string) => void
onError?: (error: string) => void
openOnLoad?: boolean
} & Partial<Omit<
ComponentProps<typeof FieldSetWithStatus>,
'tagOptions'
>>) {
const {
id,
tags,
tagOptions,
onChange,
onError,
openOnLoad,
...rest
} = props;
const ref = useRef<HTMLInputElement>(null);
const [errorMessageLocal, setErrorMessageLocal] = useState('');
useEffect(() => {
if (openOnLoad) {
const timeout = setTimeout(() => {
ref.current?.querySelectorAll('input')[0]?.focus();
}, 100);
return () => clearTimeout(timeout);
}
}, [openOnLoad]);
return (
<div ref={ref}>
<FieldSetWithStatus
{...rest}
inputRef={ref}
id={id ?? 'tags'}
value={tags}
tagOptions={convertTagsForForm(tagOptions)}
onChange={tags => {
onChange(tags);
const validationMessage = getValidationMessageForTags(tags) ?? '';
onError?.(validationMessage);
setErrorMessageLocal(validationMessage);
}}
error={errorMessageLocal}
/>
</div>
);
}

View File

@ -17,6 +17,7 @@ import Nav from '@/site/Nav';
import Footer from '@/site/Footer';
import CommandK from '@/site/CommandK';
import SwrConfigClient from '../state/SwrConfigClient';
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import '../site/globals.css';
import '../site/sonner.css';
@ -84,6 +85,7 @@ export default function RootLayout({
'lg:mx-6 lg:mb-6',
)}>
<Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} />
<AdminBatchEditPanel />
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',

View File

@ -12,7 +12,12 @@ export default function Container({
children: ReactNode
className?: string
color?: 'gray' | 'blue' | 'red' | 'yellow'
padding?: 'loose' | 'normal' | 'tight'
padding?:
'loose' |
'normal' |
'tight' |
'tight-cta-right' |
'tight-cta-right-left'
centered?: boolean
spaceChildren?: boolean
} ) {
@ -46,6 +51,8 @@ export default function Container({
case 'loose': return 'p-4 md:p-24';
case 'normal': return 'p-4 md:p-8';
case 'tight': return 'py-1.5 px-2.5';
case 'tight-cta-right': return 'py-1.5 pl-2.5 pr-1.5';
case 'tight-cta-right-left': return 'py-1.5 px-1.5';
}
};

View File

@ -29,7 +29,7 @@ export default function FieldSetWithStatus({
hideLabel,
}: {
id: string
label: string
label?: string
note?: string
error?: string
value: string
@ -37,7 +37,7 @@ export default function FieldSetWithStatus({
onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag []
tagOptions?: AnnotatedTag[]
placeholder?: string
loading?: boolean
required?: boolean
@ -55,7 +55,7 @@ export default function FieldSetWithStatus({
'space-y-1',
type === 'checkbox' && 'flex items-center gap-2',
)}>
{!hideLabel &&
{!hideLabel && label &&
<label
className={clsx(
'flex gap-2 items-center select-none',

View File

@ -4,40 +4,51 @@ import AnimateItems from './AnimateItems';
import { IoInformationCircleOutline } from 'react-icons/io5';
import { clsx } from 'clsx/lite';
export default function Note({
children,
className,
color = 'blue',
icon,
animate,
}: {
export default function Note(props: {
icon?: ReactNode
animate?: boolean
cta?: ReactNode
hideIcon?: boolean
} & ComponentProps<typeof Container>) {
const {
icon,
animate,
cta,
hideIcon,
color = 'blue',
padding,
children,
...rest
} = props;
return (
<AnimateItems
type={animate ? 'bottom' : 'none'}
items={[
<Container
key="Banner"
className={className}
centered={false}
padding="tight"
color={color}
padding={padding ?? (cta ? 'tight-cta-right' : 'tight')}
{...rest}
>
<div className="flex items-center gap-2.5 pb-[1px]">
<span className={clsx(
'w-5 flex justify-center shrink-0',
'opacity-90',
)}>
{icon ?? <IoInformationCircleOutline
size={19}
className="translate-x-[0.5px] translate-y-[0.5px]"
/>}
</span>
<span className="text-sm">
<div className="flex items-center gap-2.5 w-full">
{!hideIcon &&
<span className={clsx(
'w-5 flex justify-center shrink-0',
'opacity-90',
)}>
{icon ?? <IoInformationCircleOutline
size={19}
className="translate-x-[0.5px] translate-y-[0.5px]"
/>}
</span>}
<span className="text-sm grow">
{children}
</span>
{cta &&
<span>
{cta}
</span>}
</div>
</Container>,
]}

View File

@ -0,0 +1,60 @@
'use client';
import { clsx } from 'clsx/lite';
import Checkbox from './primitives/Checkbox';
import { useAppState } from '@/state/AppState';
import Spinner from './Spinner';
export default function SelectTileOverlay({
isSelected,
onSelectChange,
}: {
isSelected: boolean
onSelectChange: () => void
}) {
const { isPerformingSelectEdit } = useAppState();
return (
<div className={clsx(
'absolute w-full h-full cursor-pointer',
'active:bg-gray-950/40 active:dark:bg-gray-950/60',
isPerformingSelectEdit && 'pointer-events-none',
)}>
{/* Admin Select Border */}
<div
className="w-full h-full"
onClick={onSelectChange}
>
<div
className={clsx(
'w-full h-full',
'border-black dark:border-white',
// 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',
)}
/>
</div>
{/* Admin Select Action */}
<div className="absolute top-0 right-0 p-2">
{isPerformingSelectEdit
? isSelected
? <Spinner
size={16}
color="text"
className="m-[1px]"
/>
: null
: <Checkbox
className={clsx(
'text-white',
// Required to prevent Safari jitter
'translate-x-[0.1px]',
)}
checked={isSelected}
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

@ -260,7 +260,9 @@ export default function TagInput({
className={clsx(
'grow !min-w-0 !p-0 -my-2 text-xl',
'!border-none !ring-transparent',
'placeholder:text-dim',
'placeholder:text-dim placeholder:text-[14px]',
'placeholder:translate-x-[2px]',
'placeholder:translate-y-[-1.5px]',
)}
size={10}
value={inputText}

View File

@ -15,6 +15,9 @@ import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
PATH_ROOT,
PATH_SIGN_IN,
pathForPhoto,
pathForTag,
@ -23,7 +26,7 @@ import Modal from '../Modal';
import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
import Spinner from '../Spinner';
import { useRouter } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
@ -42,6 +45,7 @@ import { Tags, addHiddenToTags, formatTag } from '@/tag';
import { FaTag } from 'react-icons/fa';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import CommandKItem from './CommandKItem';
import { GRID_HOMEPAGE_ENABLED } from '@/site/config';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
@ -73,11 +77,15 @@ export default function CommandKClient({
showDebugTools?: boolean
footer?: string
}) {
const pathname = usePathname();
const {
isUserSignedIn,
setUserEmail,
isCommandKOpen: isOpen,
hiddenPhotosCount,
selectedPhotoIds,
setSelectedPhotoIds,
arePhotosMatted,
shouldShowBaselineGrid,
shouldDebugImageFallbacks,
@ -246,16 +254,27 @@ export default function CommandKClient({
});
}
const pagesItems: CommandKItem[] = [{
label: 'Home',
path: PATH_ROOT,
}];
if (GRID_HOMEPAGE_ENABLED) {
pagesItems.push({
label: 'Feed',
path: PATH_FEED_INFERRED,
});
} else {
pagesItems.push({
label: 'Grid',
path: PATH_GRID_INFERRED,
});
}
const sectionPages: CommandKSection = {
heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
items: pagesItems,
};
const adminSection: CommandKSection = {
@ -278,6 +297,17 @@ export default function CommandKClient({
label: 'App Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}, {
label: selectedPhotoIds === undefined
? 'Select Multiple Photos'
: 'Exit Select Multiple Photos',
annotation: <BiLockAlt />,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
: undefined,
action: selectedPhotoIds === undefined
? () => setSelectedPhotoIds?.([])
: () => setSelectedPhotoIds?.(undefined),
}] as CommandKItem[])
.concat(showDebugTools
? [{
@ -393,15 +423,20 @@ export default function CommandKClient({
value={key}
keywords={keywords}
onSelect={() => {
if (action) {
action();
if (!path) { setIsOpen?.(false); }
}
if (path) {
setKeyPending(key);
startTransition(async () => {
shouldCloseAfterPending.current = true;
router.push(path, { scroll: true });
});
} else {
setIsOpen?.(false);
action?.();
if (path !== pathname) {
setKeyPending(key);
startTransition(async () => {
shouldCloseAfterPending.current = true;
router.push(path, { scroll: true });
});
} else {
setIsOpen?.(false);
}
}
}}
accessory={accessory}

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);
@ -38,20 +42,21 @@ export default function MoreMenuItem({
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
)}
onClick={e => {
e.preventDefault();
if (href) {
onClick={async e => {
if (shouldPreventDefault) { e.preventDefault(); }
if (action) {
const result = action();
if (result instanceof Promise) {
setIsLoading(true);
await result.finally(() => setIsLoading(false));
}
}
if (href && href !== pathname) {
if (Boolean(hrefDownloadName)) {
window.open(href, '_blank');
} else {
startTransition(() => router.push(href));
}
} else {
const result = action?.();
if (result instanceof Promise) {
setIsLoading(true);
result.finally(() => setIsLoading(false));
}
}
}}
>

View File

@ -0,0 +1,46 @@
import { clsx } from 'clsx/lite';
import { InputHTMLAttributes, ReactNode, useRef } from 'react';
import { ImCheckboxUnchecked, ImCheckboxChecked } from 'react-icons/im';
const ICON_CLASS_NAME = 'text-[1rem]';
export default function Checkbox(props: {
children?: ReactNode
} & InputHTMLAttributes<HTMLInputElement>) {
const {
children,
className,
type: _type,
...rest
} = props;
const inputRef = useRef<HTMLInputElement>(null);
return (
<label
className={clsx(
'inline-flex items-center gap-2 text-main',
'cursor-pointer active:opacity-50',
className,
)}
onClick={() => {
if (inputRef.current) {
inputRef.current.checked = !inputRef.current.checked;
}
}}
>
<input
{...rest}
ref={inputRef}
type="checkbox"
className="hidden"
/>
<span>
{rest.checked
? <ImCheckboxChecked className={ICON_CLASS_NAME} />
: <ImCheckboxUnchecked className={ICON_CLASS_NAME} />}
</span>
{children && <span>
{children}
</span>}
</label>
);
}

View File

@ -10,6 +10,7 @@ export default function LoaderButton(props: {
spinnerColor?: SpinnerColor
styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean
confirmText?: string
shouldPreventDefault?: boolean
primary?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) {
@ -20,6 +21,7 @@ export default function LoaderButton(props: {
spinnerColor,
styleAs = 'button',
hideTextOnMobile = true,
confirmText,
shouldPreventDefault,
primary,
type = 'button',
@ -35,7 +37,9 @@ export default function LoaderButton(props: {
type={type}
onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); }
onClick?.(e);
if (!confirmText || confirm(confirmText)) {
onClick?.(e);
}
}}
className={clsx(
...(styleAs !== 'button'
@ -55,7 +59,7 @@ export default function LoaderButton(props: {
>
{(icon || isLoading) &&
<span className={clsx(
'min-w-[1.25rem] h-4',
'min-w-[1.25rem] max-h-5 overflow-hidden',
styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
'inline-flex justify-center shrink-0',
)}>
@ -63,9 +67,7 @@ export default function LoaderButton(props: {
? <Spinner
size={14}
color={spinnerColor}
className={styleAs === 'button'
? 'translate-y-[2px]'
: 'translate-y-[0.5px]'}
className="translate-y-[0.5px]"
/>
: icon}
</span>}

View File

@ -1,3 +1,5 @@
'use client';
import { Photo } from '.';
import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite';
@ -5,6 +7,8 @@ import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { GRID_ASPECT_RATIO, HIGH_DENSITY_GRID } from '@/site/config';
import { useAppState } from '@/state/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay';
export default function PhotoGrid({
photos,
@ -21,6 +25,7 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly = true,
additionalTile,
small,
canSelect,
onLastPhotoVisible,
onAnimationComplete,
}: {
@ -38,9 +43,16 @@ export default function PhotoGrid({
staggerOnFirstLoadOnly?: boolean
additionalTile?: JSX.Element
small?: boolean
canSelect?: boolean
onLastPhotoVisible?: () => void
onAnimationComplete?: () => void
}) {
const {
isUserSignedIn,
selectedPhotoIds,
setSelectedPhotoIds,
} = useAppState();
return (
<AnimateItems
className={clsx(
@ -60,12 +72,14 @@ export default function PhotoGrid({
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
onAnimationComplete={onAnimationComplete}
items={photos.map((photo, index) =>
<div
items={photos.map((photo, index) =>{
const isSelected = selectedPhotoIds?.includes(photo.id) ?? false;
return <div
key={photo.id}
className={GRID_ASPECT_RATIO !== 0
? 'flex relative overflow-hidden'
: undefined}
className={clsx(
GRID_ASPECT_RATIO !== 0 && 'flex relative overflow-hidden',
'group',
)}
style={{
...GRID_ASPECT_RATIO !== 0 && {
aspectRatio: GRID_ASPECT_RATIO,
@ -73,7 +87,11 @@ export default function PhotoGrid({
}}
>
<PhotoMedium
className="flex w-full h-full"
className={clsx(
'flex w-full h-full',
// Prevent photo navigation when selecting
selectedPhotoIds?.length !== undefined && 'pointer-events-none',
)}
{...{
photo,
tag,
@ -87,7 +105,16 @@ export default function PhotoGrid({
: undefined,
}}
/>
</div>).concat(additionalTile ?? [])}
{isUserSignedIn && canSelect && selectedPhotoIds !== undefined &&
<SelectTileOverlay
isSelected={isSelected}
onSelectChange={() => setSelectedPhotoIds?.(isSelected
? (selectedPhotoIds ?? []).filter(id => id !== photo.id)
: (selectedPhotoIds ?? []).concat(photo.id),
)}
/>}
</div>;
}).concat(additionalTile ?? [])}
itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])}
/>

View File

@ -1,14 +1,11 @@
'use client';
import SiteGrid from '@/components/SiteGrid';
import { Photo } from '.';
import PhotoGrid from './PhotoGrid';
import PhotoGridInfinite from './PhotoGridInfinite';
import { Camera } from '@/camera';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';
import { FilmSimulation } from '@/simulation';
import { useCallback, useState } from 'react';
import { ComponentProps, useCallback, useState } from 'react';
export default function PhotoGridContainer({
cacheKey,
@ -21,18 +18,13 @@ export default function PhotoGridContainer({
animateOnFirstLoadOnly,
header,
sidebar,
canSelect,
}: {
cacheKey: string
photos: Photo[]
count: number
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
animateOnFirstLoadOnly?: boolean
header?: JSX.Element
sidebar?: JSX.Element
}) {
} & ComponentProps<typeof PhotoGrid>) {
const [
shouldAnimateDynamicItems,
setShouldAnimateDynamicItems,
@ -63,6 +55,7 @@ export default function PhotoGridContainer({
focal,
animateOnFirstLoadOnly,
onAnimationComplete,
canSelect,
}} />
{count > initialOffset &&
<PhotoGridInfinite {...{
@ -74,6 +67,7 @@ export default function PhotoGridContainer({
simulation,
focal,
animateOnFirstLoadOnly,
canSelect,
}} />}
</div>
</div>}

View File

@ -1,10 +1,9 @@
'use client';
import { Camera } from '@/camera';
import { INFINITE_SCROLL_GRID_MULTIPLE } from '.';
import InfinitePhotoScroll from './InfinitePhotoScroll';
import PhotoGrid from './PhotoGrid';
import { FilmSimulation } from '@/simulation';
import { ComponentProps } from 'react';
export default function PhotoGridInfinite({
cacheKey,
@ -15,16 +14,11 @@ export default function PhotoGridInfinite({
simulation,
focal,
animateOnFirstLoadOnly,
canSelect,
}: {
cacheKey: string
initialOffset: number
canStart?: boolean
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
animateOnFirstLoadOnly?: boolean
}) {
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
return (
<InfinitePhotoScroll
cacheKey={cacheKey}
@ -44,6 +38,7 @@ export default function PhotoGridInfinite({
focal,
onLastPhotoVisible,
animateOnFirstLoadOnly,
canSelect,
}} />}
</InfinitePhotoScroll>
);

View File

@ -1,3 +1,5 @@
'use client';
import { Tags } from '@/tag';
import { Photo } from '.';
import { Cameras } from '@/camera';
@ -5,6 +7,8 @@ import { FilmSimulations } from '@/simulation';
import { PATH_GRID } from '@/site/paths';
import PhotoGridSidebar from './PhotoGridSidebar';
import PhotoGridContainer from './PhotoGridContainer';
import { useEffect } from 'react';
import { useAppState } from '@/state/AppState';
export default function PhotoGridPage({
photos,
@ -19,6 +23,18 @@ export default function PhotoGridPage({
cameras: Cameras
simulations: FilmSimulations
}) {
const { setSelectedPhotoIds } = useAppState();
useEffect(
() => {
return () => {
console.log('PhotoGridPage: unmount');
setSelectedPhotoIds?.(undefined);
};
},
[setSelectedPhotoIds]
);
return (
<PhotoGridContainer
cacheKey={`page-${PATH_GRID}`}
@ -32,6 +48,7 @@ export default function PhotoGridPage({
photosCount,
}} />
</div>}
canSelect
/>
);
}

View File

@ -8,6 +8,7 @@ import {
renamePhotoTagGlobally,
getPhoto,
getPhotos,
addTagsToPhotos,
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import {
@ -47,6 +48,7 @@ import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc';
import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
// Private actions
@ -203,6 +205,18 @@ export const updatePhotoAction = async (formData: FormData) =>
redirect(PATH_ADMIN_PHOTOS);
});
export const tagMultiplePhotosAction = (
tags: string,
photoIds: string[],
) =>
runAuthenticatedAdminServerAction(async () => {
await addTagsToPhotos(
convertStringToArray(tags, false) ?? [],
photoIds,
);
revalidateAllKeysAndPaths();
});
export const toggleFavoritePhotoAction = async (
photoId: string,
shouldRedirect?: boolean,
@ -222,6 +236,17 @@ export const toggleFavoritePhotoAction = async (
}
});
export const deletePhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
const photo = await getPhoto(photoId);
if (photo) {
await deletePhoto(photoId).then(() => deleteFile(photo.url));
}
}
revalidateAllKeysAndPaths();
});
export const deletePhotoAction = async (
photoId: string,
photoUrl: string,
@ -235,14 +260,6 @@ export const deletePhotoAction = async (
}
});
export const deletePhotoFormAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(() =>
deletePhotoAction(
formData.get('id') as string,
formData.get('url') as string,
)
);
export const deletePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string;

View File

@ -244,17 +244,19 @@ export const renamePhotoTagGlobally = (tag: string, updatedTag: string) =>
`, 'renamePhotoTagGlobally');
export const addTagsToPhotos = (tags: string[], photoIds: string[]) =>
safelyQueryPhotos(() => sql`
safelyQueryPhotos(() => query(`
UPDATE photos
SET tags = (
SELECT array_agg(DISTINCT elem)
FROM unnest(
array_cat(tags, ARRAY${convertArrayToPostgresString(tags, 'brackets')})
array_cat(tags, $1)
) AS elem
)
WHERE id IN ${convertArrayToPostgresString(photoIds, 'brackets')}
LIMIT ${photoIds.length}
`, 'addTagsToPhotos');
WHERE id = ANY($2)
`, [
convertArrayToPostgresString(tags),
convertArrayToPostgresString(photoIds),
]), 'addTagsToPhotos');
export const deletePhoto = (id: string) =>
safelyQueryPhotos(() => sql`

View File

@ -194,13 +194,19 @@ export const titleForPhoto = (photo: Photo) =>
export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(photo);
export const photoLabelForCount = (count: number) =>
count === 1 ? 'Photo' : 'Photos';
export const photoLabelForCount = (count: number, capitalize = true) =>
capitalize
? count === 1 ? 'Photo' : 'Photos'
: count === 1 ? 'photo' : 'photos';
export const photoQuantityText = (count: number, includeParentheses = true) =>
export const photoQuantityText = (
count: number,
includeParentheses = true,
capitalize?: boolean,
) =>
includeParentheses
? `(${count} ${photoLabelForCount(count)})`
: `${count} ${photoLabelForCount(count)}`;
? `(${count} ${photoLabelForCount(count, capitalize)})`
: `${count} ${photoLabelForCount(count, capitalize)}`;
export const deleteConfirmationTextForPhoto = (photo: Photo) =>
`Are you sure you want to delete "${titleForPhoto(photo)}?"`;
@ -219,7 +225,7 @@ export const descriptionForPhotoSet = (
: [
explicitCount ?? photos.length,
descriptor,
photoLabelForCount(explicitCount ?? photos.length),
photoLabelForCount(explicitCount ?? photos.length, false),
].join(' ');
const sortPhotosByDate = (

View File

@ -43,11 +43,13 @@ export const sql = <T extends QueryResultRow>(
export const convertArrayToPostgresString = (
array?: string[],
type: 'braces' | 'brackets' = 'braces',
type: 'braces' | 'brackets' | 'parentheses' = 'braces',
) => array
? type === 'braces'
? `{${array.join(',')}}`
: `[${array.map(i => `'${i}'`).join(',')}]`
: type === 'brackets'
? `[${array.map(i => `'${i}'`).join(',')}]`
: `(${array.map(i => `'${i}'`).join(',')})`
: null;
const isTemplateStringsArray = (

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

@ -4,9 +4,8 @@ import IconFeed from '@/site/IconFeed';
import IconGrid from '@/site/IconGrid';
import {
PATH_ADMIN_PHOTOS,
PATH_FEED,
PATH_GRID,
PATH_ROOT,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
} from '@/site/paths';
import { BiLockAlt } from 'react-icons/bi';
import IconSearch from './IconSearch';
@ -27,7 +26,7 @@ export default function ViewSwitcher({
const renderItemFeed = () =>
<SwitcherItem
icon={<IconFeed />}
href={GRID_HOMEPAGE_ENABLED ? PATH_FEED : PATH_ROOT}
href={PATH_FEED_INFERRED}
active={currentSelection === 'feed'}
noPadding
/>;
@ -35,7 +34,7 @@ export default function ViewSwitcher({
const renderItemGrid = () =>
<SwitcherItem
icon={<IconGrid />}
href={GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID}
href={PATH_GRID_INFERRED}
active={currentSelection === 'grid'}
noPadding
/>;

View File

@ -1,5 +1,5 @@
import { Photo } from '@/photo';
import { BASE_URL } from './config';
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { parameterize } from '@/utility/string';
@ -13,6 +13,10 @@ export const PATH_ADMIN = '/admin';
export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og';
// eslint-disable-next-line max-len
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID;
// eslint-disable-next-line max-len
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_FEED : PATH_ROOT;
// Path prefixes
export const PREFIX_PHOTO = '/p';

View File

@ -22,6 +22,10 @@ export interface AppStateContext {
adminUpdateTimes?: Date[]
registerAdminUpdate?: () => void
hiddenPhotosCount?: number
selectedPhotoIds?: string[]
setSelectedPhotoIds?: Dispatch<SetStateAction<string[] | undefined>>
isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
// DEBUG
arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>

View File

@ -34,6 +34,10 @@ export default function AppStateProvider({
useState<Date[]>([]);
const [hiddenPhotosCount, setHiddenPhotosCount] =
useState(0);
const [selectedPhotoIds, setSelectedPhotoIds] =
useState<string[] | undefined>();
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false);
// DEBUG
const [arePhotosMatted, setArePhotosMatted] =
useState(MATTE_PHOTOS);
@ -92,6 +96,10 @@ export default function AppStateProvider({
adminUpdateTimes,
registerAdminUpdate,
hiddenPhotosCount,
selectedPhotoIds,
setSelectedPhotoIds,
isPerformingSelectEdit,
setIsPerformingSelectEdit,
// DEBUG
arePhotosMatted,
setArePhotosMatted,