Select All Photos (2 of 2) (#375)
* Enable select all toggle * Extend category path test coverage * Preview queries when selecting all * Hoist select all query count to app state * Refine select photo behavior/presentation * Refactor batch edit actions * Refactor limit handling in path-based photo queries * Show all tags in admin views * Fix select all z-order
This commit is contained in:
parent
ed6a5e4908
commit
3607d51c06
@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-len */
|
||||
import {
|
||||
getEscapePath,
|
||||
getPathComponents,
|
||||
@ -11,41 +12,71 @@ import {
|
||||
isPathProtected,
|
||||
isPathTag,
|
||||
isPathTagPhoto,
|
||||
PATH_ADMIN,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_FULL,
|
||||
PATH_GRID,
|
||||
PATH_OG,
|
||||
PATH_OG_ALL,
|
||||
PATH_OG_SAMPLE,
|
||||
PATH_ROOT,
|
||||
PREFIX_ALBUM,
|
||||
PREFIX_CAMERA,
|
||||
PREFIX_FILM,
|
||||
PREFIX_FOCAL_LENGTH,
|
||||
PREFIX_LENS,
|
||||
PREFIX_RECENTS,
|
||||
PREFIX_RECIPE,
|
||||
PREFIX_TAG,
|
||||
PREFIX_YEAR,
|
||||
} from '@/app/path';
|
||||
import { TAG_PRIVATE } from '@/tag';
|
||||
|
||||
const PHOTO_ID = 'UsKSGcbt';
|
||||
const TAG = 'tag-name';
|
||||
const CAMERA_MAKE = 'fujifilm';
|
||||
const CAMERA_MODEL = 'x-t1';
|
||||
const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL };
|
||||
const FILM = 'acros';
|
||||
const FOCAL_LENGTH = 90;
|
||||
const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
|
||||
|
||||
const PATH_ROOT = '/';
|
||||
const PATH_GRID = '/grid';
|
||||
const PATH_FULL = '/full';
|
||||
const PATH_ADMIN = '/admin/photos';
|
||||
const PATH_OG = '/og';
|
||||
const PATH_OG_ALL = `${PATH_OG}/all`;
|
||||
const PATH_OG_SAMPLE = `${PATH_OG}/sample`;
|
||||
const PHOTO_ID = 'UsKSGcbt';
|
||||
const YEAR = '2025';
|
||||
const CAMERA_MAKE = 'fujifilm';
|
||||
const CAMERA_MODEL = 'x-t1';
|
||||
const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL };
|
||||
const LENS_MAKE = 'fujifilm';
|
||||
const LENS_MODEL = 'xf90mmf2-r-lm-wr';
|
||||
const LENS_OBJECT = { make: LENS_MAKE, model: LENS_MODEL };
|
||||
const ALBUM = 'album-name';
|
||||
const TAG = 'tag-name';
|
||||
const RECIPE = 'nature-nurture';
|
||||
const FILM = 'acros';
|
||||
const FOCAL_LENGTH = 90;
|
||||
const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
|
||||
|
||||
const PATH_PHOTO = `/p/${PHOTO_ID}`;
|
||||
|
||||
const PATH_TAG = `/tag/${TAG}`;
|
||||
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
|
||||
const PATH_RECENTS = PREFIX_RECENTS;
|
||||
const PATH_RECENTS_PHOTO = `${PATH_RECENTS}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_TAG_PRIVATE = `/tag/${TAG_PRIVATE}`;
|
||||
const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`;
|
||||
const PATH_YEAR = `${PREFIX_YEAR}/${YEAR}`;
|
||||
const PATH_YEAR_PHOTO = `${PATH_YEAR}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
|
||||
const PATH_CAMERA = `${PREFIX_CAMERA}/${CAMERA_MAKE}/${CAMERA_MODEL}`;
|
||||
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_FILM = `/film/${FILM}`;
|
||||
const PATH_LENS = `${PREFIX_LENS}/${LENS_MAKE}/${LENS_MODEL}`;
|
||||
const PATH_LENS_PHOTO = `${PATH_LENS}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_ALBUM = `${PREFIX_ALBUM}/${ALBUM}`;
|
||||
const PATH_ALBUM_PHOTO = `${PATH_ALBUM}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_TAG = `${PREFIX_TAG}/${TAG}`;
|
||||
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_TAG_PRIVATE = `${PREFIX_TAG}/${TAG_PRIVATE}`;
|
||||
const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_RECIPE = `${PREFIX_RECIPE}/${RECIPE}`;
|
||||
const PATH_RECIPE_PHOTO = `${PATH_RECIPE}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_FILM = `${PREFIX_FILM}/${FILM}`;
|
||||
const PATH_FILM_PHOTO = `${PATH_FILM}/${PHOTO_ID}`;
|
||||
|
||||
const PATH_FOCAL_LENGTH = `/focal/${FOCAL_LENGTH_STRING}`;
|
||||
const PATH_FOCAL_LENGTH = `${PREFIX_FOCAL_LENGTH}/${FOCAL_LENGTH_STRING}`;
|
||||
const PATH_FOCAL_LENGTH_PHOTO = `${PATH_FOCAL_LENGTH}/${PHOTO_ID}`;
|
||||
|
||||
describe('Paths', () => {
|
||||
@ -59,6 +90,7 @@ describe('Paths', () => {
|
||||
expect(isPathProtected(PATH_FILM)).toBe(false);
|
||||
// Private
|
||||
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
||||
expect(isPathProtected(PATH_ADMIN_PHOTOS)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
|
||||
@ -68,10 +100,10 @@ describe('Paths', () => {
|
||||
it('can be classified', () => {
|
||||
// Positive
|
||||
expect(isPathPhoto(PATH_PHOTO)).toBe(true);
|
||||
expect(isPathTag(PATH_TAG)).toBe(true);
|
||||
expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true);
|
||||
expect(isPathCamera(PATH_CAMERA)).toBe(true);
|
||||
expect(isPathCameraPhoto(PATH_CAMERA_PHOTO)).toBe(true);
|
||||
expect(isPathTag(PATH_TAG)).toBe(true);
|
||||
expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true);
|
||||
expect(isPathFilm(PATH_FILM)).toBe(true);
|
||||
expect(isPathFilmPhoto(PATH_FILM_PHOTO)).toBe(true);
|
||||
expect(isPathFocalLength(PATH_FOCAL_LENGTH)).toBe(true);
|
||||
@ -86,13 +118,21 @@ describe('Paths', () => {
|
||||
expect(getPathComponents(PATH_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
});
|
||||
// Tag
|
||||
expect(getPathComponents(PATH_TAG)).toEqual({
|
||||
tag: TAG,
|
||||
// Recents
|
||||
expect(getPathComponents(PATH_RECENTS)).toEqual({
|
||||
recent: true,
|
||||
});
|
||||
expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({
|
||||
expect(getPathComponents(PATH_RECENTS_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
tag: TAG,
|
||||
recent: true,
|
||||
});
|
||||
// Year
|
||||
expect(getPathComponents(PATH_YEAR)).toEqual({
|
||||
year: YEAR,
|
||||
});
|
||||
expect(getPathComponents(PATH_YEAR_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
year: YEAR,
|
||||
});
|
||||
// Camera
|
||||
expect(getPathComponents(PATH_CAMERA)).toEqual({
|
||||
@ -102,6 +142,38 @@ describe('Paths', () => {
|
||||
photoId: PHOTO_ID,
|
||||
camera: CAMERA_OBJECT,
|
||||
});
|
||||
// Lens
|
||||
expect(getPathComponents(PATH_LENS)).toEqual({
|
||||
lens: LENS_OBJECT,
|
||||
});
|
||||
expect(getPathComponents(PATH_LENS_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
lens: LENS_OBJECT,
|
||||
});
|
||||
// Album
|
||||
expect(getPathComponents(PATH_ALBUM)).toEqual({
|
||||
album: ALBUM,
|
||||
});
|
||||
expect(getPathComponents(PATH_ALBUM_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
album: ALBUM,
|
||||
});
|
||||
// Tag
|
||||
expect(getPathComponents(PATH_TAG)).toEqual({
|
||||
tag: TAG,
|
||||
});
|
||||
expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
tag: TAG,
|
||||
});
|
||||
// Recipe
|
||||
expect(getPathComponents(PATH_RECIPE)).toEqual({
|
||||
recipe: RECIPE,
|
||||
});
|
||||
expect(getPathComponents(PATH_RECIPE_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
recipe: RECIPE,
|
||||
});
|
||||
// Film
|
||||
expect(getPathComponents(PATH_FILM)).toEqual({
|
||||
film: FILM,
|
||||
@ -127,12 +199,27 @@ describe('Paths', () => {
|
||||
expect(getEscapePath(PATH_ADMIN)).toEqual(undefined);
|
||||
// Photo
|
||||
expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT);
|
||||
// Tag
|
||||
expect(getEscapePath(PATH_TAG)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG);
|
||||
// Recents
|
||||
expect(getEscapePath(PATH_RECENTS)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_RECENTS_PHOTO)).toEqual(PATH_RECENTS);
|
||||
// Year
|
||||
expect(getEscapePath(PATH_YEAR)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_YEAR_PHOTO)).toEqual(PATH_YEAR);
|
||||
// Camera
|
||||
expect(getEscapePath(PATH_CAMERA)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_CAMERA_PHOTO)).toEqual(PATH_CAMERA);
|
||||
// Lens
|
||||
expect(getEscapePath(PATH_LENS)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_LENS_PHOTO)).toEqual(PATH_LENS);
|
||||
// Album
|
||||
expect(getEscapePath(PATH_ALBUM)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_ALBUM_PHOTO)).toEqual(PATH_ALBUM);
|
||||
// Tag
|
||||
expect(getEscapePath(PATH_TAG)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG);
|
||||
// Recipe
|
||||
expect(getEscapePath(PATH_RECIPE)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_RECIPE_PHOTO)).toEqual(PATH_RECIPE);
|
||||
// Film
|
||||
expect(getEscapePath(PATH_FILM)).toEqual(PATH_ROOT);
|
||||
expect(getEscapePath(PATH_FILM_PHOTO)).toEqual(PATH_FILM);
|
||||
|
||||
@ -23,8 +23,8 @@ export default async function TagPageEdit({
|
||||
{ count },
|
||||
photos,
|
||||
] = await Promise.all([
|
||||
getPhotosMetaCached({ tag }),
|
||||
getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }),
|
||||
getPhotosMetaCached({ tag, hidden: 'include' }),
|
||||
getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW, hidden: 'include' }),
|
||||
]);
|
||||
|
||||
if (count === 0) { redirect(PATH_ADMIN); }
|
||||
|
||||
@ -3,7 +3,7 @@ import AppGrid from '@/components/AppGrid';
|
||||
import { getUniqueTags } from '@/photo/query';
|
||||
|
||||
export default async function AdminTagsPage() {
|
||||
const tags = await getUniqueTags().catch(() => []);
|
||||
const tags = await getUniqueTags(true).catch(() => []);
|
||||
|
||||
return (
|
||||
<AppGrid
|
||||
|
||||
@ -36,7 +36,7 @@ export default async function AdminNav() {
|
||||
}),
|
||||
getAlbumsWithMetaCached().then(albums => albums.length)
|
||||
.catch(() => 0),
|
||||
getUniqueTagsCached().then(tags => tags.length)
|
||||
getUniqueTagsCached(true).then(tags => tags.length)
|
||||
.catch(() => 0),
|
||||
getUniqueRecipesCached().then(recipes => recipes.length)
|
||||
.catch(() => 0),
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import { batchPhotoAction } from '@/photo/actions';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { toastSuccess, toastWarning } from '@/toast';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
|
||||
export default function DeletePhotosButton({
|
||||
photoIds = [],
|
||||
photoOptions,
|
||||
photosText,
|
||||
onDelete,
|
||||
clearLocalState = true,
|
||||
onClick,
|
||||
@ -20,6 +21,8 @@ export default function DeletePhotosButton({
|
||||
...rest
|
||||
}: {
|
||||
photoIds?: string[]
|
||||
photoOptions?: PhotoQueryOptions
|
||||
photosText?: string
|
||||
onClick?: () => void
|
||||
onFinish?: () => void
|
||||
onDelete?: () => void
|
||||
@ -28,10 +31,6 @@ export default function DeletePhotosButton({
|
||||
} & ComponentProps<typeof LoaderButton>) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const appText = useAppText();
|
||||
|
||||
const photosText = photoQuantityText(photoIds.length, appText, false, false);
|
||||
|
||||
const { invalidateSwr, registerAdminUpdate } = useAppState();
|
||||
|
||||
return (
|
||||
@ -45,6 +44,7 @@ export default function DeletePhotosButton({
|
||||
setIsLoading(true);
|
||||
batchPhotoAction({
|
||||
photoIds,
|
||||
photoOptions,
|
||||
action: 'delete',
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Note from '@/components/Note';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -27,21 +26,22 @@ import { convertStringToArray } from '@/utility/string';
|
||||
export default function AdminBatchEditPanelClient({
|
||||
uniqueAlbums,
|
||||
uniqueTags,
|
||||
showSelectAll,
|
||||
}: {
|
||||
uniqueAlbums: Albums
|
||||
uniqueTags: Tags
|
||||
showSelectAll?: boolean
|
||||
}) {
|
||||
const refNote = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
canCurrentPageSelectPhotos,
|
||||
shouldShowSelectAll,
|
||||
isSelectingPhotos,
|
||||
stopSelectingPhotos,
|
||||
isSelectingAllPhotos,
|
||||
toggleIsSelectingAllPhotos,
|
||||
selectedPhotoIds,
|
||||
selectAllPhotoOptions,
|
||||
selectAllCount,
|
||||
isPerformingSelectEdit,
|
||||
setIsPerformingSelectEdit,
|
||||
} = useSelectPhotosState();
|
||||
@ -55,8 +55,17 @@ export default function AdminBatchEditPanelClient({
|
||||
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||
const isInTagMode = tags !== undefined;
|
||||
|
||||
const batchPhotoActionArguments = (
|
||||
isSelectingAllPhotos &&
|
||||
selectAllPhotoOptions
|
||||
)
|
||||
? { photoOptions: selectAllPhotoOptions }
|
||||
: { photoIds: selectedPhotoIds };
|
||||
|
||||
const photosText = photoQuantityText(
|
||||
selectedPhotoIds?.length ?? 0,
|
||||
(isSelectingAllPhotos && selectAllCount !== undefined
|
||||
? selectAllCount
|
||||
: selectedPhotoIds?.length) ?? 0,
|
||||
appText,
|
||||
false,
|
||||
false,
|
||||
@ -64,18 +73,26 @@ export default function AdminBatchEditPanelClient({
|
||||
|
||||
const isFormDisabled =
|
||||
isPerformingSelectEdit ||
|
||||
selectedPhotoIds?.length === 0;
|
||||
isSelectingAllPhotos
|
||||
? !Boolean(selectAllCount)
|
||||
: selectedPhotoIds?.length === 0;
|
||||
|
||||
const renderPhotoCTA = selectedPhotoIds?.length === 0
|
||||
? <>
|
||||
<FaArrowDown />
|
||||
<ResponsiveText shortText="Select">
|
||||
Select photos below
|
||||
const renderPhotoSelectionStatus = isSelectingAllPhotos
|
||||
? selectAllCount === undefined
|
||||
? 'Selecting ...'
|
||||
: <ResponsiveText shortText={`${selectAllCount} selected`}>
|
||||
{`${selectAllCount} photos selected`}
|
||||
</ResponsiveText>
|
||||
</>
|
||||
: <ResponsiveText shortText={photosText}>
|
||||
{photosText} selected
|
||||
</ResponsiveText>;
|
||||
: selectedPhotoIds?.length === 0
|
||||
? <>
|
||||
<FaArrowDown />
|
||||
<ResponsiveText shortText="Select">
|
||||
Select photos below
|
||||
</ResponsiveText>
|
||||
</>
|
||||
: <ResponsiveText shortText={photosText}>
|
||||
{photosText} selected
|
||||
</ResponsiveText>;
|
||||
|
||||
const renderActions = isInTagMode || isInAlbumMode
|
||||
? <>
|
||||
@ -104,7 +121,7 @@ export default function AdminBatchEditPanelClient({
|
||||
setIsPerformingSelectEdit?.(true);
|
||||
if (isInTagMode) {
|
||||
batchPhotoAction({
|
||||
photoIds: selectedPhotoIds,
|
||||
...batchPhotoActionArguments,
|
||||
tags: convertStringToArray(tags, false),
|
||||
})
|
||||
.then(() => {
|
||||
@ -114,7 +131,7 @@ export default function AdminBatchEditPanelClient({
|
||||
.finally(() => setIsPerformingSelectEdit?.(false));
|
||||
} else if (isInAlbumMode) {
|
||||
batchPhotoAction({
|
||||
photoIds: selectedPhotoIds,
|
||||
...batchPhotoActionArguments,
|
||||
albumTitles: albumTitles.split(','),
|
||||
})
|
||||
.then(() => {
|
||||
@ -129,8 +146,7 @@ export default function AdminBatchEditPanelClient({
|
||||
(!tags || Boolean(tagErrorMessage)) &&
|
||||
!albumTitles
|
||||
) ||
|
||||
(selectedPhotoIds?.length ?? 0) === 0 ||
|
||||
isPerformingSelectEdit
|
||||
isFormDisabled
|
||||
}
|
||||
primary
|
||||
>
|
||||
@ -139,7 +155,10 @@ export default function AdminBatchEditPanelClient({
|
||||
</>
|
||||
: <>
|
||||
<DeletePhotosButton
|
||||
photoIds={selectedPhotoIds}
|
||||
{...{
|
||||
...batchPhotoActionArguments,
|
||||
photosText,
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => setIsPerformingSelectEdit?.(true)}
|
||||
onDelete={stopSelectingPhotos}
|
||||
@ -152,7 +171,7 @@ export default function AdminBatchEditPanelClient({
|
||||
onClick={() => {
|
||||
setIsPerformingSelectEdit?.(true);
|
||||
batchPhotoAction({
|
||||
photoIds: selectedPhotoIds,
|
||||
...batchPhotoActionArguments,
|
||||
action: 'favorite',
|
||||
})
|
||||
.then(() => {
|
||||
@ -196,62 +215,62 @@ export default function AdminBatchEditPanelClient({
|
||||
return shouldShowPanel
|
||||
? <AppGrid
|
||||
className="sticky top-0 z-10 -mt-2 pt-2"
|
||||
contentMain={<div className="flex flex-col gap-2">
|
||||
<Note
|
||||
contentMain={
|
||||
<div
|
||||
ref={refNote}
|
||||
color="gray"
|
||||
className={clsx(
|
||||
'min-h-[3.5rem] pr-2',
|
||||
'backdrop-blur-lg border-transparent!',
|
||||
'flex flex-col gap-2',
|
||||
'p-2 rounded-xl',
|
||||
'backdrop-blur-lg',
|
||||
'text-gray-900! dark:text-gray-100!',
|
||||
'bg-gray-100/90! dark:bg-gray-900/70!',
|
||||
// Override default <Note /> content spacing
|
||||
'[&>*>*:first-child]:gap-1.5 sm:[&>*>*:first-child]:gap-2.5',
|
||||
'outline outline-medium',
|
||||
'shadow-xl/5',
|
||||
)}
|
||||
padding={isInTagMode ? 'tight-cta-right-left' : 'tight-cta-right'}
|
||||
cta={<div className="flex items-center gap-1.5 sm:gap-2.5">
|
||||
{renderActions}
|
||||
</div>}
|
||||
spaceChildren={false}
|
||||
hideIcon
|
||||
>
|
||||
{isInAlbumMode
|
||||
? <FieldsetAlbum
|
||||
albumOptions={uniqueAlbums}
|
||||
value={albumTitles}
|
||||
onChange={setAlbumsTitles}
|
||||
readOnly={isPerformingSelectEdit}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
: isInTagMode
|
||||
? <FieldsetTag
|
||||
tags={tags}
|
||||
tagOptions={uniqueTags}
|
||||
placeholder={`Tag ${photosText} ...`}
|
||||
onChange={setTags}
|
||||
onError={setTagErrorMessage}
|
||||
<div className="flex items-center gap-2 [&>*:first-child]:grow">
|
||||
{isInAlbumMode
|
||||
? <FieldsetAlbum
|
||||
albumOptions={uniqueAlbums}
|
||||
value={albumTitles}
|
||||
onChange={setAlbumsTitles}
|
||||
readOnly={isPerformingSelectEdit}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
: <div>
|
||||
<div className="text-base flex gap-2 items-center">
|
||||
{renderPhotoCTA}
|
||||
</div>
|
||||
{showSelectAll &&
|
||||
<FieldsetWithStatus
|
||||
label="Select All Photos"
|
||||
type="checkbox"
|
||||
value={isSelectingAllPhotos ? 'true' : 'false'}
|
||||
onChange={toggleIsSelectingAllPhotos}
|
||||
/>}
|
||||
</div>}
|
||||
</Note>
|
||||
{tagErrorMessage &&
|
||||
<div className="text-error pl-4">
|
||||
{tagErrorMessage}
|
||||
</div>}
|
||||
</div>} />
|
||||
: isInTagMode
|
||||
? <FieldsetTag
|
||||
tags={tags}
|
||||
tagOptions={uniqueTags}
|
||||
placeholder={`Tag ${photosText} ...`}
|
||||
onChange={setTags}
|
||||
onError={setTagErrorMessage}
|
||||
readOnly={isPerformingSelectEdit}
|
||||
openOnLoad
|
||||
hideLabel
|
||||
/>
|
||||
: <div className="grow">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderPhotoSelectionStatus}
|
||||
</div>
|
||||
</div>}
|
||||
{renderActions}
|
||||
</div>
|
||||
{shouldShowSelectAll &&
|
||||
<FieldsetWithStatus
|
||||
label="Select All"
|
||||
type="checkbox"
|
||||
className="-z-10"
|
||||
value={isSelectingAllPhotos ? 'true' : 'false'}
|
||||
onChange={toggleIsSelectingAllPhotos}
|
||||
readOnly={isSelectingAllPhotos &&
|
||||
selectAllCount === undefined}
|
||||
/>}
|
||||
{tagErrorMessage &&
|
||||
<div className="text-error pl-4">
|
||||
{tagErrorMessage}
|
||||
</div>}
|
||||
</div>} />
|
||||
: null;
|
||||
}
|
||||
|
||||
@ -2,12 +2,18 @@
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SelectPhotosContext } from './SelectPhotosState';
|
||||
import { PARAM_SELECT, PATH_GRID_INFERRED } from '@/app/path';
|
||||
import {
|
||||
getPathComponents,
|
||||
PARAM_SELECT,
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/path';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import useClientSearchParams from '@/utility/useClientSearchParams';
|
||||
import { replacePathWithEvent } from '@/utility/url';
|
||||
import { isElementPartiallyInViewport } from '@/utility/dom';
|
||||
import { getPhotoOptionsCountForPathAction } from '@/photo/actions';
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
|
||||
export const DATA_KEY_PHOTO_GRID = 'data-photo-grid';
|
||||
|
||||
@ -20,6 +26,11 @@ export default function SelectPhotosProvider({
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const shouldShowSelectAll = useMemo(() => {
|
||||
const { photoId } = getPathComponents(pathname);
|
||||
return photoId === undefined;
|
||||
}, [pathname]);
|
||||
|
||||
const { isUserSignedIn } = useAppState();
|
||||
|
||||
const searchParamsSelect = useClientSearchParams(
|
||||
@ -34,6 +45,9 @@ export default function SelectPhotosProvider({
|
||||
useState<string[]>([]);
|
||||
const [isSelectingAllPhotos, setIsSelectingAllPhotos] =
|
||||
useState(false);
|
||||
const [selectAllPhotoOptions, setSelectAllPhotoOptions] =
|
||||
useState<PhotoQueryOptions>();
|
||||
const [selectAllCount, setSelectAllCount] = useState<number>();
|
||||
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
|
||||
useState(false);
|
||||
|
||||
@ -78,9 +92,17 @@ export default function SelectPhotosProvider({
|
||||
}, [isSelectingAllPhotos, selectedPhotoIds]);
|
||||
|
||||
const toggleIsSelectingAllPhotos = useCallback(() => {
|
||||
setIsSelectingAllPhotos(prev => !prev);
|
||||
setIsSelectingAllPhotos(!isSelectingAllPhotos);
|
||||
setSelectedPhotoIds([]);
|
||||
}, []);
|
||||
if (!isSelectingAllPhotos) {
|
||||
getPhotoOptionsCountForPathAction(pathname)
|
||||
.then(({ options, count }) => {
|
||||
setSelectAllPhotoOptions(options);
|
||||
setSelectAllCount(count);
|
||||
})
|
||||
.catch(() => setIsSelectingAllPhotos(false));
|
||||
}
|
||||
}, [isSelectingAllPhotos, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectingPhotos) {
|
||||
@ -94,6 +116,8 @@ export default function SelectPhotosProvider({
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedPhotoIds([]);
|
||||
setIsSelectingAllPhotos(false);
|
||||
setSelectAllPhotoOptions(undefined);
|
||||
setSelectAllCount(undefined);
|
||||
}
|
||||
}, [isSelectingPhotos, getPhotoGridElements]);
|
||||
|
||||
@ -102,10 +126,13 @@ export default function SelectPhotosProvider({
|
||||
canCurrentPageSelectPhotos,
|
||||
isSelectingPhotos,
|
||||
isSelectingAllPhotos,
|
||||
shouldShowSelectAll,
|
||||
toggleIsSelectingAllPhotos,
|
||||
startSelectingPhotos,
|
||||
stopSelectingPhotos,
|
||||
selectedPhotoIds,
|
||||
selectAllPhotoOptions,
|
||||
selectAllCount,
|
||||
togglePhotoSelection,
|
||||
isPerformingSelectEdit,
|
||||
setIsPerformingSelectEdit,
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { PhotoQueryOptions } from '@/db';
|
||||
import { createContext, Dispatch, SetStateAction, use } from 'react';
|
||||
|
||||
export type SelectPhotosState = {
|
||||
canCurrentPageSelectPhotos?: boolean
|
||||
isSelectingPhotos?: boolean
|
||||
isSelectingAllPhotos?: boolean
|
||||
shouldShowSelectAll?: boolean
|
||||
toggleIsSelectingAllPhotos?: () => void
|
||||
startSelectingPhotos?: () => void
|
||||
stopSelectingPhotos?: () => void
|
||||
selectedPhotoIds?: string[]
|
||||
selectAllPhotoOptions?: PhotoQueryOptions
|
||||
selectAllCount?: number
|
||||
togglePhotoSelection?: (photoId: string) => void
|
||||
isPerformingSelectEdit?: boolean
|
||||
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
|
||||
|
||||
@ -42,6 +42,8 @@ export const PATH_FEED_JSON = '/feed.json';
|
||||
|
||||
// Path prefixes
|
||||
export const PREFIX_PHOTO = '/p';
|
||||
export const PREFIX_RECENTS = '/recents';
|
||||
export const PREFIX_YEAR = '/year';
|
||||
export const PREFIX_CAMERA = '/shot-on';
|
||||
export const PREFIX_LENS = '/lens';
|
||||
export const PREFIX_ALBUM = '/album';
|
||||
@ -49,20 +51,18 @@ export const PREFIX_TAG = '/tag';
|
||||
export const PREFIX_RECIPE = '/recipe';
|
||||
export const PREFIX_FILM = '/film';
|
||||
export const PREFIX_FOCAL_LENGTH = '/focal';
|
||||
export const PREFIX_YEAR = '/year';
|
||||
export const PREFIX_RECENTS = '/recents';
|
||||
|
||||
// Dynamic paths
|
||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||
const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
|
||||
const PATH_YEAR_DYNAMIC = `${PREFIX_YEAR}/[year]`;
|
||||
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
||||
const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`;
|
||||
const PATH_ALBUM_DYNAMIC = `${PREFIX_ALBUM}/[album]`;
|
||||
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
|
||||
const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`;
|
||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
|
||||
const PATH_YEAR_DYNAMIC = `${PREFIX_YEAR}/[year]`;
|
||||
const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
|
||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||
|
||||
// Admin paths
|
||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
||||
@ -467,57 +467,81 @@ export const getPathComponents = (
|
||||
}) => {
|
||||
const photoIdFromPhoto = pathname.match(
|
||||
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
|
||||
const recent = (
|
||||
isPathRecents(pathname) ||
|
||||
isPathRecentsPhoto(pathname)
|
||||
) ? true : undefined;
|
||||
const photoIdFromRecents = pathname.match(
|
||||
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1];
|
||||
const year = pathname.match(
|
||||
new RegExp(`^${PREFIX_YEAR}/([^/]+)`))?.[1];
|
||||
const photoIdFromCamera = pathname.match(
|
||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1];
|
||||
const cameraMake = pathname.match(
|
||||
new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1];
|
||||
const cameraModel = pathname.match(
|
||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromLens = pathname.match(
|
||||
new RegExp(`^${PREFIX_LENS}/[^/]+/[^/]+/([^/]+)`))?.[1];
|
||||
const lensMake = pathname.match(
|
||||
new RegExp(`^${PREFIX_LENS}/([^/]+)`))?.[1];
|
||||
const lensModel = pathname.match(
|
||||
new RegExp(`^${PREFIX_LENS}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromAlbum = pathname.match(
|
||||
new RegExp(`^${PREFIX_ALBUM}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromTag = pathname.match(
|
||||
new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromRecipe = pathname.match(
|
||||
new RegExp(`^${PREFIX_RECIPE}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromFilm = pathname.match(
|
||||
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromFocalLength = pathname.match(
|
||||
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
|
||||
const photoIdFromYear = pathname.match(
|
||||
new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1];
|
||||
const photoIdFromRecents = pathname.match(
|
||||
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1];
|
||||
const album = pathname.match(
|
||||
new RegExp(`^${PREFIX_ALBUM}/([^/]+)`))?.[1];
|
||||
const tag = pathname.match(
|
||||
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
|
||||
const recipe = pathname.match(
|
||||
new RegExp(`^${PREFIX_RECIPE}/([^/]+)`))?.[1];
|
||||
const film = pathname.match(
|
||||
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
|
||||
const focalString = pathname.match(
|
||||
new RegExp(`^${PREFIX_FOCAL_LENGTH}/([0-9]+)mm`))?.[1];
|
||||
const year = pathname.match(
|
||||
new RegExp(`^${PREFIX_YEAR}/([^/]+)`))?.[1];
|
||||
const recent = isPathRecents(pathname) ? true : undefined;
|
||||
|
||||
const camera = cameraMake && cameraModel
|
||||
? { make: cameraMake, model: cameraModel }
|
||||
: undefined;
|
||||
|
||||
const lens = lensMake && lensModel
|
||||
? { make: lensMake, model: lensModel }
|
||||
: undefined;
|
||||
|
||||
const focal = focalString ? parseInt(focalString) : undefined;
|
||||
|
||||
return {
|
||||
photoId: (
|
||||
photoIdFromPhoto ||
|
||||
photoIdFromTag ||
|
||||
photoIdFromCamera ||
|
||||
photoIdFromFilm ||
|
||||
photoIdFromFocalLength ||
|
||||
photoIdFromRecents ||
|
||||
photoIdFromYear ||
|
||||
photoIdFromRecents
|
||||
photoIdFromCamera ||
|
||||
photoIdFromLens ||
|
||||
photoIdFromAlbum ||
|
||||
photoIdFromTag ||
|
||||
photoIdFromRecipe ||
|
||||
photoIdFromFilm ||
|
||||
photoIdFromFocalLength
|
||||
),
|
||||
recent,
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
camera,
|
||||
recipe,
|
||||
film,
|
||||
focal,
|
||||
year,
|
||||
recent,
|
||||
};
|
||||
};
|
||||
|
||||
@ -541,6 +565,7 @@ export const getEscapePath = (pathname?: string) => {
|
||||
(year && isPathYear(pathname)) ||
|
||||
(camera && isPathCamera(pathname)) ||
|
||||
(lens && isPathLens(pathname)) ||
|
||||
(album && isPathAlbum(pathname)) ||
|
||||
(tag && isPathTag(pathname)) ||
|
||||
(film && isPathFilm(pathname)) ||
|
||||
(focal && isPathFocalLength(pathname)) ||
|
||||
|
||||
@ -4,6 +4,10 @@ import { Camera } from '@/camera';
|
||||
import { Lens } from '@/lens';
|
||||
import { APP_DEFAULT_SORT_BY, SortBy } from '@/photo/sort';
|
||||
import { Album } from '@/album';
|
||||
import { getPathComponents } from '@/app/path';
|
||||
import { getAlbumFromSlug } from '@/album/query';
|
||||
import { isTagPrivate } from '@/tag';
|
||||
import { getPhotoCount } from '@/photo/query';
|
||||
|
||||
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||
@ -254,3 +258,30 @@ export const generateManyToManyValues = (idsA: string[], idsB: string[]) => {
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPhotoOptionsCountForPath = async (
|
||||
path: string,
|
||||
): Promise<{ options: PhotoQueryOptions, count: number }> => {
|
||||
const { album: albumSlug, tag, ...components } = getPathComponents(path);
|
||||
|
||||
let album: Album | undefined;
|
||||
if (albumSlug) {
|
||||
album = await getAlbumFromSlug(albumSlug);
|
||||
}
|
||||
|
||||
const options: PhotoQueryOptions = {
|
||||
album,
|
||||
...isTagPrivate(tag) ? { hidden: 'only' } : { tag },
|
||||
...components,
|
||||
};
|
||||
|
||||
const count = await getPhotoCount(options);
|
||||
|
||||
return {
|
||||
options: {
|
||||
...options,
|
||||
limit: count,
|
||||
},
|
||||
count,
|
||||
};
|
||||
};
|
||||
|
||||
@ -16,7 +16,11 @@ import {
|
||||
getColorDataForPhotos,
|
||||
getPhotoIds,
|
||||
} from '@/photo/query';
|
||||
import { PhotoQueryOptions, areOptionsSensitive } from '@/db';
|
||||
import {
|
||||
PhotoQueryOptions,
|
||||
areOptionsSensitive,
|
||||
getPhotoOptionsCountForPath,
|
||||
} from '@/db';
|
||||
import {
|
||||
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
|
||||
PhotoFormData,
|
||||
@ -702,6 +706,11 @@ export const getImageBlurAction = async (url: string) =>
|
||||
|
||||
// Batch actions
|
||||
|
||||
export const getPhotoOptionsCountForPathAction = async (path: string) =>
|
||||
runAuthenticatedAdminServerAction(async () =>
|
||||
getPhotoOptionsCountForPath(path),
|
||||
);
|
||||
|
||||
export const batchPhotoAction = async ({
|
||||
photoIds: _photoIds = [],
|
||||
photoOptions,
|
||||
|
||||
@ -294,20 +294,20 @@ export const getUniqueLenses = async () =>
|
||||
})))
|
||||
, 'getUniqueLenses');
|
||||
|
||||
export const getUniqueTags = async () =>
|
||||
safelyQuery(() => sql`
|
||||
export const getUniqueTags = async (includeHidden?: boolean) =>
|
||||
safelyQuery(() => query(`
|
||||
SELECT DISTINCT unnest(tags) as tag,
|
||||
COUNT(*),
|
||||
MAX(updated_at) as last_modified
|
||||
FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
${includeHidden ? '' : 'WHERE hidden IS NOT TRUE'}
|
||||
GROUP BY tag
|
||||
ORDER BY tag ASC
|
||||
`.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
|
||||
tag,
|
||||
count: parseInt(count, 10),
|
||||
lastModified: last_modified as Date,
|
||||
})))
|
||||
`).then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
|
||||
tag,
|
||||
count: parseInt(count, 10),
|
||||
lastModified: last_modified as Date,
|
||||
})))
|
||||
, 'getUniqueTags');
|
||||
|
||||
export const getUniqueRecipes = async () =>
|
||||
@ -422,8 +422,13 @@ export const getUniqueFocalLengths = async () =>
|
||||
|
||||
const _getPhotos = async (
|
||||
options: PhotoQueryOptions = {},
|
||||
fields = ['*'],
|
||||
shouldParse = true,
|
||||
fields = ['*'], {
|
||||
shouldParse = true,
|
||||
includeOrderBy = true,
|
||||
}: {
|
||||
shouldParse?: boolean,
|
||||
includeOrderBy?: boolean,
|
||||
} = {},
|
||||
) => {
|
||||
const sql = [
|
||||
`SELECT ${fields.map(field => `p.${field}`).join(', ')} FROM photos p`,
|
||||
@ -446,7 +451,9 @@ const _getPhotos = async (
|
||||
values.push(...wheresValues);
|
||||
}
|
||||
|
||||
sql.push(getOrderByFromOptions(options));
|
||||
if (includeOrderBy) {
|
||||
sql.push(getOrderByFromOptions(options));
|
||||
}
|
||||
|
||||
const {
|
||||
limitAndOffset,
|
||||
@ -478,7 +485,7 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) =>
|
||||
|
||||
export const getPhotoIds = async (options: PhotoQueryOptions = {}) =>
|
||||
safelyQuery(
|
||||
async () => _getPhotos(options, ['id'], false)
|
||||
async () => _getPhotos(options, ['id'], { shouldParse: false })
|
||||
.then(({ photos }) => photos.map(photo => photo.id)),
|
||||
'getPhotoIds',
|
||||
// Seemingly necessary to pass `options` for expected cache behavior
|
||||
@ -487,7 +494,11 @@ export const getPhotoIds = async (options: PhotoQueryOptions = {}) =>
|
||||
|
||||
export const getPhotoUrls = async (options: PhotoQueryOptions = {}) =>
|
||||
safelyQuery(
|
||||
async () => _getPhotos(options, ['id', 'title', 'url', 'hidden'], false)
|
||||
async () => _getPhotos(
|
||||
options,
|
||||
['id', 'title', 'url', 'hidden'],
|
||||
{ shouldParse: false },
|
||||
)
|
||||
.then(({ photos }) =>
|
||||
photos as {
|
||||
id: string,
|
||||
@ -502,7 +513,11 @@ export const getPhotoUrls = async (options: PhotoQueryOptions = {}) =>
|
||||
|
||||
export const getPhotoCount = async (options: PhotoQueryOptions = {}) =>
|
||||
safelyQuery(
|
||||
async () => _getPhotos(options, ['COUNT(*)'], false)
|
||||
async () => _getPhotos(
|
||||
options,
|
||||
['COUNT'],
|
||||
{ shouldParse: false, includeOrderBy: false },
|
||||
)
|
||||
.then(({ count }) => count),
|
||||
'getPhotoCount',
|
||||
// Seemingly necessary to pass `options` for expected cache behavior
|
||||
|
||||
@ -19,8 +19,8 @@ import { CategoryQueryMeta, sortCategoryByCount } from '@/category';
|
||||
import { AppTextState } from '@/i18n/state';
|
||||
|
||||
// Reserved tags
|
||||
export const TAG_FAVS = 'favs';
|
||||
export const TAG_PRIVATE = 'private';
|
||||
export const TAG_FAVS = 'favs';
|
||||
export const TAG_PRIVATE = 'private';
|
||||
|
||||
type TagWithMeta = { tag: string } & CategoryQueryMeta;
|
||||
|
||||
@ -147,7 +147,8 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
export const isPathFavs = (pathname?: string) =>
|
||||
getPathComponents(pathname).tag === TAG_FAVS;
|
||||
|
||||
export const isTagPrivate = (tag: string) => tag.toLowerCase() === TAG_PRIVATE;
|
||||
export const isTagPrivate = (tag = '') =>
|
||||
tag.toLocaleLowerCase() === TAG_PRIVATE;
|
||||
|
||||
export const addPrivateToTags = (
|
||||
tags: Tags,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user