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? #### 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`. > 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? #### 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." > 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 Container from '@/components/Container';
import { addAllUploadsAction } from '@/photo/actions'; import { addAllUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { import { Tags } from '@/tag';
Tags,
convertTagsForForm,
getValidationMessageForTags,
} from '@/tag';
import { import {
generateLocalNaivePostgresString, generateLocalNaivePostgresString,
generateLocalPostgresString, generateLocalPostgresString,
@ -22,6 +18,7 @@ import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle, BiImageAdd } from 'react-icons/bi'; import { BiCheckCircle, BiImageAdd } from 'react-icons/bi';
import ProgressButton from '@/components/primitives/ProgressButton'; import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient'; import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset';
const UPLOAD_BATCH_SIZE = 4; const UPLOAD_BATCH_SIZE = 4;
@ -38,8 +35,6 @@ export default function AdminAddAllUploads({
setIsAdding: (isAdding: boolean) => void setIsAdding: (isAdding: boolean) => void
setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>> setUrlAddStatuses: Dispatch<SetStateAction<UrlAddStatus[]>>
}) { }) {
const divRef = useRef<HTMLDivElement>(null);
const [buttonText, setButtonText] = useState('Add All Uploads'); const [buttonText, setButtonText] = useState('Add All Uploads');
const [showTags, setShowTags] = useState(false); const [showTags, setShowTags] = useState(false);
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
@ -121,36 +116,20 @@ export default function AdminAddAllUploads({
label="Apply tags" label="Apply tags"
type="checkbox" type="checkbox"
value={showTags ? 'true' : 'false'} value={showTags ? 'true' : 'false'}
onChange={value => { onChange={value => setShowTags(value === 'true')}
setShowTags(value === 'true');
if (value === 'true') {
setTimeout(() =>
divRef.current?.querySelectorAll('input')[0]?.focus()
, 100);
}
}}
readOnly={isAdding} readOnly={isAdding}
/> />
</div> </div>
<div {showTags && !actionErrorMessage &&
ref={divRef} <PhotoTagFieldset
className={showTags && !actionErrorMessage ? undefined : 'hidden'} tags={tags}
> tagOptions={uniqueTags}
<FieldSetWithStatus onChange={setTags}
id="tags" onError={setTagErrorMessage}
label="Optional Tags"
tagOptions={convertTagsForForm(uniqueTags)}
value={tags}
onChange={tags => {
setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}}
readOnly={isAdding} readOnly={isAdding}
error={tagErrorMessage} openOnLoad
required={false}
hideLabel hideLabel
/> />}
</div>
<div className="space-y-2"> <div className="space-y-2">
<ProgressButton <ProgressButton
primary 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 { isPathFavs, isPhotoFav } from '@/tag';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi'; 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 { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md'; import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
export default function AdminPhotoMenuClient({ export default function AdminPhotoMenuClient({
photo, photo,
@ -33,7 +34,7 @@ export default function AdminPhotoMenuClient({
const favIconClass = 'translate-x-[-1px] translate-y-[0.5px]'; const favIconClass = 'translate-x-[-1px] translate-y-[0.5px]';
const items = useMemo(() => { const items = useMemo(() => {
const items: MoreMenuItem[] = [{ const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit', label: 'Edit',
icon: <FaRegEdit size={14} />, icon: <FaRegEdit size={14} />,
href: pathForAdminPhotoEdit(photo.id), href: pathForAdminPhotoEdit(photo.id),

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { clsx } from 'clsx/lite';
import { ComponentProps, useCallback } from 'react'; import { ComponentProps, useCallback } from 'react';
import { BiTrash } from 'react-icons/bi'; import { BiTrash } from 'react-icons/bi';
export default function DeleteButton ( export default function DeleteFormButton (
props: ComponentProps<typeof SubmitButtonWithStatus> & { props: ComponentProps<typeof SubmitButtonWithStatus> & {
clearLocalState?: boolean 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 Footer from '@/site/Footer';
import CommandK from '@/site/CommandK'; import CommandK from '@/site/CommandK';
import SwrConfigClient from '../state/SwrConfigClient'; import SwrConfigClient from '../state/SwrConfigClient';
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import '../site/globals.css'; import '../site/globals.css';
import '../site/sonner.css'; import '../site/sonner.css';
@ -84,6 +85,7 @@ export default function RootLayout({
'lg:mx-6 lg:mb-6', 'lg:mx-6 lg:mb-6',
)}> )}>
<Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} /> <Nav siteDomainOrTitle={SITE_DOMAIN_OR_TITLE} />
<AdminBatchEditPanel />
<div className={clsx( <div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]', 'min-h-[16rem] sm:min-h-[30rem]',
'mb-12', 'mb-12',

View File

@ -12,7 +12,12 @@ export default function Container({
children: ReactNode children: ReactNode
className?: string className?: string
color?: 'gray' | 'blue' | 'red' | 'yellow' color?: 'gray' | 'blue' | 'red' | 'yellow'
padding?: 'loose' | 'normal' | 'tight' padding?:
'loose' |
'normal' |
'tight' |
'tight-cta-right' |
'tight-cta-right-left'
centered?: boolean centered?: boolean
spaceChildren?: boolean spaceChildren?: boolean
} ) { } ) {
@ -46,6 +51,8 @@ export default function Container({
case 'loose': return 'p-4 md:p-24'; case 'loose': return 'p-4 md:p-24';
case 'normal': return 'p-4 md:p-8'; case 'normal': return 'p-4 md:p-8';
case 'tight': return 'py-1.5 px-2.5'; 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, hideLabel,
}: { }: {
id: string id: string
label: string label?: string
note?: string note?: string
error?: string error?: string
value: string value: string
@ -37,7 +37,7 @@ export default function FieldSetWithStatus({
onChange?: (value: string) => void onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[] selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag [] tagOptions?: AnnotatedTag[]
placeholder?: string placeholder?: string
loading?: boolean loading?: boolean
required?: boolean required?: boolean
@ -55,7 +55,7 @@ export default function FieldSetWithStatus({
'space-y-1', 'space-y-1',
type === 'checkbox' && 'flex items-center gap-2', type === 'checkbox' && 'flex items-center gap-2',
)}> )}>
{!hideLabel && {!hideLabel && label &&
<label <label
className={clsx( className={clsx(
'flex gap-2 items-center select-none', 'flex gap-2 items-center select-none',

View File

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

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

View File

@ -15,6 +15,9 @@ import {
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
PATH_FEED_INFERRED,
PATH_GRID_INFERRED,
PATH_ROOT,
PATH_SIGN_IN, PATH_SIGN_IN,
pathForPhoto, pathForPhoto,
pathForTag, pathForTag,
@ -23,7 +26,7 @@ import Modal from '../Modal';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5'; import { IoInvertModeSharp } from 'react-icons/io5';
@ -42,6 +45,7 @@ import { Tags, addHiddenToTags, formatTag } from '@/tag';
import { FaTag } from 'react-icons/fa'; import { FaTag } from 'react-icons/fa';
import { formatCount, formatCountDescriptive } from '@/utility/string'; import { formatCount, formatCountDescriptive } from '@/utility/string';
import CommandKItem from './CommandKItem'; import CommandKItem from './CommandKItem';
import { GRID_HOMEPAGE_ENABLED } from '@/site/config';
const LISTENER_KEYDOWN = 'keydown'; const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2; const MINIMUM_QUERY_LENGTH = 2;
@ -73,11 +77,15 @@ export default function CommandKClient({
showDebugTools?: boolean showDebugTools?: boolean
footer?: string footer?: string
}) { }) {
const pathname = usePathname();
const { const {
isUserSignedIn, isUserSignedIn,
setUserEmail, setUserEmail,
isCommandKOpen: isOpen, isCommandKOpen: isOpen,
hiddenPhotosCount, hiddenPhotosCount,
selectedPhotoIds,
setSelectedPhotoIds,
arePhotosMatted, arePhotosMatted,
shouldShowBaselineGrid, shouldShowBaselineGrid,
shouldDebugImageFallbacks, 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 = { const sectionPages: CommandKSection = {
heading: 'Pages', heading: 'Pages',
accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />, accessory: <HiDocumentText size={15} className="translate-x-[-1px]" />,
items: ([{ items: pagesItems,
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}]),
}; };
const adminSection: CommandKSection = { const adminSection: CommandKSection = {
@ -278,6 +297,17 @@ export default function CommandKClient({
label: 'App Config', label: 'App Config',
annotation: <BiLockAlt />, annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION, 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[]) }] as CommandKItem[])
.concat(showDebugTools .concat(showDebugTools
? [{ ? [{
@ -393,15 +423,20 @@ export default function CommandKClient({
value={key} value={key}
keywords={keywords} keywords={keywords}
onSelect={() => { onSelect={() => {
if (action) {
action();
if (!path) { setIsOpen?.(false); }
}
if (path) { if (path) {
setKeyPending(key); if (path !== pathname) {
startTransition(async () => { setKeyPending(key);
shouldCloseAfterPending.current = true; startTransition(async () => {
router.push(path, { scroll: true }); shouldCloseAfterPending.current = true;
}); router.push(path, { scroll: true });
} else { });
setIsOpen?.(false); } else {
action?.(); setIsOpen?.(false);
}
} }
}} }}
accessory={accessory} 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 * 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);
@ -38,20 +42,21 @@ export default function MoreMenuItem({
? 'cursor-not-allowed opacity-50' ? 'cursor-not-allowed opacity-50'
: 'cursor-pointer', : 'cursor-pointer',
)} )}
onClick={e => { onClick={async e => {
e.preventDefault(); if (shouldPreventDefault) { e.preventDefault(); }
if (href) { if (action) {
const result = action();
if (result instanceof Promise) {
setIsLoading(true);
await result.finally(() => setIsLoading(false));
}
}
if (href && href !== pathname) {
if (Boolean(hrefDownloadName)) { if (Boolean(hrefDownloadName)) {
window.open(href, '_blank'); window.open(href, '_blank');
} else { } else {
startTransition(() => router.push(href)); 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 spinnerColor?: SpinnerColor
styleAs?: 'button' | 'link' | 'link-without-hover' styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean hideTextOnMobile?: boolean
confirmText?: string
shouldPreventDefault?: boolean shouldPreventDefault?: boolean
primary?: boolean primary?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) { } & ButtonHTMLAttributes<HTMLButtonElement>) {
@ -20,6 +21,7 @@ export default function LoaderButton(props: {
spinnerColor, spinnerColor,
styleAs = 'button', styleAs = 'button',
hideTextOnMobile = true, hideTextOnMobile = true,
confirmText,
shouldPreventDefault, shouldPreventDefault,
primary, primary,
type = 'button', type = 'button',
@ -35,7 +37,9 @@ export default function LoaderButton(props: {
type={type} type={type}
onClick={e => { onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); } if (shouldPreventDefault) { e.preventDefault(); }
onClick?.(e); if (!confirmText || confirm(confirmText)) {
onClick?.(e);
}
}} }}
className={clsx( className={clsx(
...(styleAs !== 'button' ...(styleAs !== 'button'
@ -55,7 +59,7 @@ export default function LoaderButton(props: {
> >
{(icon || isLoading) && {(icon || isLoading) &&
<span className={clsx( <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]', styleAs === 'button' ? 'translate-y-[-0.5px]' : 'translate-y-[0.5px]',
'inline-flex justify-center shrink-0', 'inline-flex justify-center shrink-0',
)}> )}>
@ -63,9 +67,7 @@ export default function LoaderButton(props: {
? <Spinner ? <Spinner
size={14} size={14}
color={spinnerColor} color={spinnerColor}
className={styleAs === 'button' className="translate-y-[0.5px]"
? 'translate-y-[2px]'
: 'translate-y-[0.5px]'}
/> />
: icon} : icon}
</span>} </span>}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
renamePhotoTagGlobally, renamePhotoTagGlobally,
getPhoto, getPhoto,
getPhotos, getPhotos,
addTagsToPhotos,
} from '@/photo/db/query'; } from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db'; import { GetPhotosOptions, areOptionsSensitive } from './db';
import { import {
@ -47,6 +48,7 @@ import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc'; import { createStreamableValue } from 'ai/rsc';
import { convertUploadToPhoto } from './storage'; import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient'; import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
// Private actions // Private actions
@ -203,6 +205,18 @@ export const updatePhotoAction = async (formData: FormData) =>
redirect(PATH_ADMIN_PHOTOS); redirect(PATH_ADMIN_PHOTOS);
}); });
export const tagMultiplePhotosAction = (
tags: string,
photoIds: string[],
) =>
runAuthenticatedAdminServerAction(async () => {
await addTagsToPhotos(
convertStringToArray(tags, false) ?? [],
photoIds,
);
revalidateAllKeysAndPaths();
});
export const toggleFavoritePhotoAction = async ( export const toggleFavoritePhotoAction = async (
photoId: string, photoId: string,
shouldRedirect?: boolean, 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 ( export const deletePhotoAction = async (
photoId: string, photoId: string,
photoUrl: 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) => export const deletePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string; const tag = formData.get('tag') as string;

View File

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

View File

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

View File

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

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

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

View File

@ -1,5 +1,5 @@
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import { BASE_URL } from './config'; import { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
@ -13,6 +13,10 @@ export const PATH_ADMIN = '/admin';
export const PATH_API = '/api'; export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in'; export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og'; 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 // Path prefixes
export const PREFIX_PHOTO = '/p'; export const PREFIX_PHOTO = '/p';

View File

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

View File

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