Merge pull request #124 from sambecker/batch-edit
Allow admins to batch edit photos
This commit is contained in:
commit
03e855ab40
@ -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."
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
51
src/admin/AdminAppMenu.tsx
Normal file
51
src/admin/AdminAppMenu.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/admin/AdminBatchEditPanel.tsx
Normal file
9
src/admin/AdminBatchEditPanel.tsx
Normal 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 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/admin/AdminBatchEditPanelClient.tsx
Normal file
178
src/admin/AdminBatchEditPanelClient.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
21
src/admin/DeletePhotoButton.tsx
Normal file
21
src/admin/DeletePhotoButton.tsx
Normal 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`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/admin/DeletePhotosButton.tsx
Normal file
73
src/admin/DeletePhotosButton.tsx
Normal 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?.();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/admin/PhotoTagFieldset.tsx
Normal file
58
src/admin/PhotoTagFieldset.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
60
src/components/SelectTileOverlay.tsx
Normal file
60
src/components/SelectTileOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,24 +1,16 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||||
import MoreMenuItem from './MoreMenuItem';
|
import MoreMenuItem from './MoreMenuItem';
|
||||||
|
|
||||||
export interface MoreMenuItem {
|
|
||||||
label: ReactNode
|
|
||||||
icon?: ReactNode
|
|
||||||
href?: string
|
|
||||||
hrefDownloadName?: string
|
|
||||||
action?: () => Promise<void> | void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MoreMenu({
|
export default function MoreMenu({
|
||||||
items,
|
items,
|
||||||
className,
|
className,
|
||||||
buttonClassName,
|
buttonClassName,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: {
|
}: {
|
||||||
items: MoreMenuItem[]
|
items: ComponentProps<typeof MoreMenuItem> []
|
||||||
className?: string
|
className?: string
|
||||||
buttonClassName?: string
|
buttonClassName?: string
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
@ -44,23 +36,17 @@ export default function MoreMenu({
|
|||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
align="end"
|
align="end"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
className,
|
'z-10',
|
||||||
'min-w-[8rem]',
|
'min-w-[8rem]',
|
||||||
'ml-2.5',
|
'ml-2.5',
|
||||||
'p-1 rounded-md border',
|
'p-1 rounded-md border',
|
||||||
'bg-content',
|
'bg-content',
|
||||||
'shadow-lg dark:shadow-xl',
|
'shadow-lg dark:shadow-xl',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map(({ label, icon, href, hrefDownloadName, action }) =>
|
{items.map(props =>
|
||||||
<MoreMenuItem
|
<MoreMenuItem key={`${props.label}`} {...props} />
|
||||||
key={`${label}`}
|
|
||||||
label={label}
|
|
||||||
icon={icon}
|
|
||||||
href={href}
|
|
||||||
hrefDownloadName={hrefDownloadName}
|
|
||||||
action={action}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { ReactNode, useState, useTransition } from 'react';
|
import { ReactNode, useState, useTransition } from 'react';
|
||||||
import LoaderButton from '../primitives/LoaderButton';
|
import LoaderButton from '../primitives/LoaderButton';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function MoreMenuItem({
|
export default function MoreMenuItem({
|
||||||
label,
|
label,
|
||||||
@ -12,15 +12,19 @@ export default function MoreMenuItem({
|
|||||||
href,
|
href,
|
||||||
hrefDownloadName,
|
hrefDownloadName,
|
||||||
action,
|
action,
|
||||||
|
shouldPreventDefault = true,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
href?: string
|
href?: string
|
||||||
hrefDownloadName?: string
|
hrefDownloadName?: string
|
||||||
action?: () => Promise<void> | void
|
action?: () => Promise<void> | void
|
||||||
|
shouldPreventDefault?: boolean
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
46
src/components/primitives/Checkbox.tsx
Normal file
46
src/components/primitives/Checkbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>}
|
||||||
|
|||||||
@ -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'] : [])}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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 = (
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
/>;
|
/>;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user