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:
Sam Becker 2026-02-16 09:23:28 -06:00 committed by GitHub
parent ed6a5e4908
commit 3607d51c06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 366 additions and 148 deletions

View File

@ -1,3 +1,4 @@
/* eslint-disable max-len */
import { import {
getEscapePath, getEscapePath,
getPathComponents, getPathComponents,
@ -11,41 +12,71 @@ import {
isPathProtected, isPathProtected,
isPathTag, isPathTag,
isPathTagPhoto, 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'; } from '@/app/path';
import { TAG_PRIVATE } from '@/tag'; import { TAG_PRIVATE } from '@/tag';
const PHOTO_ID = 'UsKSGcbt'; const PHOTO_ID = 'UsKSGcbt';
const TAG = 'tag-name'; const YEAR = '2025';
const CAMERA_MAKE = 'fujifilm'; const CAMERA_MAKE = 'fujifilm';
const CAMERA_MODEL = 'x-t1'; const CAMERA_MODEL = 'x-t1';
const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL }; 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 FILM = 'acros';
const FOCAL_LENGTH = 90; const FOCAL_LENGTH = 90;
const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`; 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 PATH_PHOTO = `/p/${PHOTO_ID}`; const PATH_PHOTO = `/p/${PHOTO_ID}`;
const PATH_TAG = `/tag/${TAG}`; const PATH_RECENTS = PREFIX_RECENTS;
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; const PATH_RECENTS_PHOTO = `${PATH_RECENTS}/${PHOTO_ID}`;
const PATH_TAG_PRIVATE = `/tag/${TAG_PRIVATE}`; const PATH_YEAR = `${PREFIX_YEAR}/${YEAR}`;
const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`; 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_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_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}`; const PATH_FOCAL_LENGTH_PHOTO = `${PATH_FOCAL_LENGTH}/${PHOTO_ID}`;
describe('Paths', () => { describe('Paths', () => {
@ -59,6 +90,7 @@ describe('Paths', () => {
expect(isPathProtected(PATH_FILM)).toBe(false); expect(isPathProtected(PATH_FILM)).toBe(false);
// Private // Private
expect(isPathProtected(PATH_ADMIN)).toBe(true); expect(isPathProtected(PATH_ADMIN)).toBe(true);
expect(isPathProtected(PATH_ADMIN_PHOTOS)).toBe(true);
expect(isPathProtected(PATH_OG)).toBe(true); expect(isPathProtected(PATH_OG)).toBe(true);
expect(isPathProtected(PATH_OG_ALL)).toBe(true); expect(isPathProtected(PATH_OG_ALL)).toBe(true);
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true); expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
@ -68,10 +100,10 @@ describe('Paths', () => {
it('can be classified', () => { it('can be classified', () => {
// Positive // Positive
expect(isPathPhoto(PATH_PHOTO)).toBe(true); 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(isPathCamera(PATH_CAMERA)).toBe(true);
expect(isPathCameraPhoto(PATH_CAMERA_PHOTO)).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(isPathFilm(PATH_FILM)).toBe(true);
expect(isPathFilmPhoto(PATH_FILM_PHOTO)).toBe(true); expect(isPathFilmPhoto(PATH_FILM_PHOTO)).toBe(true);
expect(isPathFocalLength(PATH_FOCAL_LENGTH)).toBe(true); expect(isPathFocalLength(PATH_FOCAL_LENGTH)).toBe(true);
@ -86,13 +118,21 @@ describe('Paths', () => {
expect(getPathComponents(PATH_PHOTO)).toEqual({ expect(getPathComponents(PATH_PHOTO)).toEqual({
photoId: PHOTO_ID, photoId: PHOTO_ID,
}); });
// Tag // Recents
expect(getPathComponents(PATH_TAG)).toEqual({ expect(getPathComponents(PATH_RECENTS)).toEqual({
tag: TAG, recent: true,
}); });
expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({ expect(getPathComponents(PATH_RECENTS_PHOTO)).toEqual({
photoId: PHOTO_ID, 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 // Camera
expect(getPathComponents(PATH_CAMERA)).toEqual({ expect(getPathComponents(PATH_CAMERA)).toEqual({
@ -102,6 +142,38 @@ describe('Paths', () => {
photoId: PHOTO_ID, photoId: PHOTO_ID,
camera: CAMERA_OBJECT, 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 // Film
expect(getPathComponents(PATH_FILM)).toEqual({ expect(getPathComponents(PATH_FILM)).toEqual({
film: FILM, film: FILM,
@ -127,12 +199,27 @@ describe('Paths', () => {
expect(getEscapePath(PATH_ADMIN)).toEqual(undefined); expect(getEscapePath(PATH_ADMIN)).toEqual(undefined);
// Photo // Photo
expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT);
// Tag // Recents
expect(getEscapePath(PATH_TAG)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_RECENTS)).toEqual(PATH_ROOT);
expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG); 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 // Camera
expect(getEscapePath(PATH_CAMERA)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_CAMERA)).toEqual(PATH_ROOT);
expect(getEscapePath(PATH_CAMERA_PHOTO)).toEqual(PATH_CAMERA); 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 // Film
expect(getEscapePath(PATH_FILM)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_FILM)).toEqual(PATH_ROOT);
expect(getEscapePath(PATH_FILM_PHOTO)).toEqual(PATH_FILM); expect(getEscapePath(PATH_FILM_PHOTO)).toEqual(PATH_FILM);

View File

@ -23,8 +23,8 @@ export default async function TagPageEdit({
{ count }, { count },
photos, photos,
] = await Promise.all([ ] = await Promise.all([
getPhotosMetaCached({ tag }), getPhotosMetaCached({ tag, hidden: 'include' }),
getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW }), getPhotosCached({ tag, limit: MAX_PHOTO_TO_SHOW, hidden: 'include' }),
]); ]);
if (count === 0) { redirect(PATH_ADMIN); } if (count === 0) { redirect(PATH_ADMIN); }

View File

@ -3,7 +3,7 @@ import AppGrid from '@/components/AppGrid';
import { getUniqueTags } from '@/photo/query'; import { getUniqueTags } from '@/photo/query';
export default async function AdminTagsPage() { export default async function AdminTagsPage() {
const tags = await getUniqueTags().catch(() => []); const tags = await getUniqueTags(true).catch(() => []);
return ( return (
<AppGrid <AppGrid

View File

@ -36,7 +36,7 @@ export default async function AdminNav() {
}), }),
getAlbumsWithMetaCached().then(albums => albums.length) getAlbumsWithMetaCached().then(albums => albums.length)
.catch(() => 0), .catch(() => 0),
getUniqueTagsCached().then(tags => tags.length) getUniqueTagsCached(true).then(tags => tags.length)
.catch(() => 0), .catch(() => 0),
getUniqueRecipesCached().then(recipes => recipes.length) getUniqueRecipesCached().then(recipes => recipes.length)
.catch(() => 0), .catch(() => 0),

View File

@ -1,16 +1,17 @@
'use client'; 'use client';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
import { photoQuantityText } from '@/photo';
import { batchPhotoAction } from '@/photo/actions'; import { batchPhotoAction } from '@/photo/actions';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import { toastSuccess, toastWarning } from '@/toast'; import { toastSuccess, toastWarning } from '@/toast';
import { ComponentProps, useState } from 'react'; import { ComponentProps, useState } from 'react';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import { useAppText } from '@/i18n/state/client'; import { PhotoQueryOptions } from '@/db';
export default function DeletePhotosButton({ export default function DeletePhotosButton({
photoIds = [], photoIds = [],
photoOptions,
photosText,
onDelete, onDelete,
clearLocalState = true, clearLocalState = true,
onClick, onClick,
@ -20,6 +21,8 @@ export default function DeletePhotosButton({
...rest ...rest
}: { }: {
photoIds?: string[] photoIds?: string[]
photoOptions?: PhotoQueryOptions
photosText?: string
onClick?: () => void onClick?: () => void
onFinish?: () => void onFinish?: () => void
onDelete?: () => void onDelete?: () => void
@ -28,10 +31,6 @@ export default function DeletePhotosButton({
} & ComponentProps<typeof LoaderButton>) { } & ComponentProps<typeof LoaderButton>) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const appText = useAppText();
const photosText = photoQuantityText(photoIds.length, appText, false, false);
const { invalidateSwr, registerAdminUpdate } = useAppState(); const { invalidateSwr, registerAdminUpdate } = useAppState();
return ( return (
@ -45,6 +44,7 @@ export default function DeletePhotosButton({
setIsLoading(true); setIsLoading(true);
batchPhotoAction({ batchPhotoAction({
photoIds, photoIds,
photoOptions,
action: 'delete', action: 'delete',
}) })
.then(() => { .then(() => {

View File

@ -1,6 +1,5 @@
'use client'; 'use client';
import Note from '@/components/Note';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
@ -27,21 +26,22 @@ import { convertStringToArray } from '@/utility/string';
export default function AdminBatchEditPanelClient({ export default function AdminBatchEditPanelClient({
uniqueAlbums, uniqueAlbums,
uniqueTags, uniqueTags,
showSelectAll,
}: { }: {
uniqueAlbums: Albums uniqueAlbums: Albums
uniqueTags: Tags uniqueTags: Tags
showSelectAll?: boolean
}) { }) {
const refNote = useRef<HTMLDivElement>(null); const refNote = useRef<HTMLDivElement>(null);
const { const {
canCurrentPageSelectPhotos, canCurrentPageSelectPhotos,
shouldShowSelectAll,
isSelectingPhotos, isSelectingPhotos,
stopSelectingPhotos, stopSelectingPhotos,
isSelectingAllPhotos, isSelectingAllPhotos,
toggleIsSelectingAllPhotos, toggleIsSelectingAllPhotos,
selectedPhotoIds, selectedPhotoIds,
selectAllPhotoOptions,
selectAllCount,
isPerformingSelectEdit, isPerformingSelectEdit,
setIsPerformingSelectEdit, setIsPerformingSelectEdit,
} = useSelectPhotosState(); } = useSelectPhotosState();
@ -55,8 +55,17 @@ export default function AdminBatchEditPanelClient({
const [tagErrorMessage, setTagErrorMessage] = useState(''); const [tagErrorMessage, setTagErrorMessage] = useState('');
const isInTagMode = tags !== undefined; const isInTagMode = tags !== undefined;
const batchPhotoActionArguments = (
isSelectingAllPhotos &&
selectAllPhotoOptions
)
? { photoOptions: selectAllPhotoOptions }
: { photoIds: selectedPhotoIds };
const photosText = photoQuantityText( const photosText = photoQuantityText(
selectedPhotoIds?.length ?? 0, (isSelectingAllPhotos && selectAllCount !== undefined
? selectAllCount
: selectedPhotoIds?.length) ?? 0,
appText, appText,
false, false,
false, false,
@ -64,9 +73,17 @@ export default function AdminBatchEditPanelClient({
const isFormDisabled = const isFormDisabled =
isPerformingSelectEdit || isPerformingSelectEdit ||
selectedPhotoIds?.length === 0; isSelectingAllPhotos
? !Boolean(selectAllCount)
: selectedPhotoIds?.length === 0;
const renderPhotoCTA = selectedPhotoIds?.length === 0 const renderPhotoSelectionStatus = isSelectingAllPhotos
? selectAllCount === undefined
? 'Selecting ...'
: <ResponsiveText shortText={`${selectAllCount} selected`}>
{`${selectAllCount} photos selected`}
</ResponsiveText>
: selectedPhotoIds?.length === 0
? <> ? <>
<FaArrowDown /> <FaArrowDown />
<ResponsiveText shortText="Select"> <ResponsiveText shortText="Select">
@ -104,7 +121,7 @@ export default function AdminBatchEditPanelClient({
setIsPerformingSelectEdit?.(true); setIsPerformingSelectEdit?.(true);
if (isInTagMode) { if (isInTagMode) {
batchPhotoAction({ batchPhotoAction({
photoIds: selectedPhotoIds, ...batchPhotoActionArguments,
tags: convertStringToArray(tags, false), tags: convertStringToArray(tags, false),
}) })
.then(() => { .then(() => {
@ -114,7 +131,7 @@ export default function AdminBatchEditPanelClient({
.finally(() => setIsPerformingSelectEdit?.(false)); .finally(() => setIsPerformingSelectEdit?.(false));
} else if (isInAlbumMode) { } else if (isInAlbumMode) {
batchPhotoAction({ batchPhotoAction({
photoIds: selectedPhotoIds, ...batchPhotoActionArguments,
albumTitles: albumTitles.split(','), albumTitles: albumTitles.split(','),
}) })
.then(() => { .then(() => {
@ -129,8 +146,7 @@ export default function AdminBatchEditPanelClient({
(!tags || Boolean(tagErrorMessage)) && (!tags || Boolean(tagErrorMessage)) &&
!albumTitles !albumTitles
) || ) ||
(selectedPhotoIds?.length ?? 0) === 0 || isFormDisabled
isPerformingSelectEdit
} }
primary primary
> >
@ -139,7 +155,10 @@ export default function AdminBatchEditPanelClient({
</> </>
: <> : <>
<DeletePhotosButton <DeletePhotosButton
photoIds={selectedPhotoIds} {...{
...batchPhotoActionArguments,
photosText,
}}
disabled={isFormDisabled} disabled={isFormDisabled}
onClick={() => setIsPerformingSelectEdit?.(true)} onClick={() => setIsPerformingSelectEdit?.(true)}
onDelete={stopSelectingPhotos} onDelete={stopSelectingPhotos}
@ -152,7 +171,7 @@ export default function AdminBatchEditPanelClient({
onClick={() => { onClick={() => {
setIsPerformingSelectEdit?.(true); setIsPerformingSelectEdit?.(true);
batchPhotoAction({ batchPhotoAction({
photoIds: selectedPhotoIds, ...batchPhotoActionArguments,
action: 'favorite', action: 'favorite',
}) })
.then(() => { .then(() => {
@ -196,25 +215,21 @@ export default function AdminBatchEditPanelClient({
return shouldShowPanel return shouldShowPanel
? <AppGrid ? <AppGrid
className="sticky top-0 z-10 -mt-2 pt-2" className="sticky top-0 z-10 -mt-2 pt-2"
contentMain={<div className="flex flex-col gap-2"> contentMain={
<Note <div
ref={refNote} ref={refNote}
color="gray" color="gray"
className={clsx( className={clsx(
'min-h-[3.5rem] pr-2', 'flex flex-col gap-2',
'backdrop-blur-lg border-transparent!', 'p-2 rounded-xl',
'backdrop-blur-lg',
'text-gray-900! dark:text-gray-100!', 'text-gray-900! dark:text-gray-100!',
'bg-gray-100/90! dark:bg-gray-900/70!', 'bg-gray-100/90! dark:bg-gray-900/70!',
// Override default <Note /> content spacing 'outline outline-medium',
'[&>*>*:first-child]:gap-1.5 sm:[&>*>*:first-child]:gap-2.5', '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
> >
<div className="flex items-center gap-2 [&>*:first-child]:grow">
{isInAlbumMode {isInAlbumMode
? <FieldsetAlbum ? <FieldsetAlbum
albumOptions={uniqueAlbums} albumOptions={uniqueAlbums}
@ -235,19 +250,23 @@ export default function AdminBatchEditPanelClient({
openOnLoad openOnLoad
hideLabel hideLabel
/> />
: <div> : <div className="grow">
<div className="text-base flex gap-2 items-center"> <div className="flex items-center gap-2">
{renderPhotoCTA} {renderPhotoSelectionStatus}
</div> </div>
{showSelectAll && </div>}
{renderActions}
</div>
{shouldShowSelectAll &&
<FieldsetWithStatus <FieldsetWithStatus
label="Select All Photos" label="Select All"
type="checkbox" type="checkbox"
className="-z-10"
value={isSelectingAllPhotos ? 'true' : 'false'} value={isSelectingAllPhotos ? 'true' : 'false'}
onChange={toggleIsSelectingAllPhotos} onChange={toggleIsSelectingAllPhotos}
readOnly={isSelectingAllPhotos &&
selectAllCount === undefined}
/>} />}
</div>}
</Note>
{tagErrorMessage && {tagErrorMessage &&
<div className="text-error pl-4"> <div className="text-error pl-4">
{tagErrorMessage} {tagErrorMessage}

View File

@ -2,12 +2,18 @@
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { SelectPhotosContext } from './SelectPhotosState'; 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 { usePathname, useRouter } from 'next/navigation';
import { useAppState } from '@/app/AppState'; import { useAppState } from '@/app/AppState';
import useClientSearchParams from '@/utility/useClientSearchParams'; import useClientSearchParams from '@/utility/useClientSearchParams';
import { replacePathWithEvent } from '@/utility/url'; import { replacePathWithEvent } from '@/utility/url';
import { isElementPartiallyInViewport } from '@/utility/dom'; import { isElementPartiallyInViewport } from '@/utility/dom';
import { getPhotoOptionsCountForPathAction } from '@/photo/actions';
import { PhotoQueryOptions } from '@/db';
export const DATA_KEY_PHOTO_GRID = 'data-photo-grid'; export const DATA_KEY_PHOTO_GRID = 'data-photo-grid';
@ -20,6 +26,11 @@ export default function SelectPhotosProvider({
const pathname = usePathname(); const pathname = usePathname();
const shouldShowSelectAll = useMemo(() => {
const { photoId } = getPathComponents(pathname);
return photoId === undefined;
}, [pathname]);
const { isUserSignedIn } = useAppState(); const { isUserSignedIn } = useAppState();
const searchParamsSelect = useClientSearchParams( const searchParamsSelect = useClientSearchParams(
@ -34,6 +45,9 @@ export default function SelectPhotosProvider({
useState<string[]>([]); useState<string[]>([]);
const [isSelectingAllPhotos, setIsSelectingAllPhotos] = const [isSelectingAllPhotos, setIsSelectingAllPhotos] =
useState(false); useState(false);
const [selectAllPhotoOptions, setSelectAllPhotoOptions] =
useState<PhotoQueryOptions>();
const [selectAllCount, setSelectAllCount] = useState<number>();
const [isPerformingSelectEdit, setIsPerformingSelectEdit] = const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false); useState(false);
@ -78,9 +92,17 @@ export default function SelectPhotosProvider({
}, [isSelectingAllPhotos, selectedPhotoIds]); }, [isSelectingAllPhotos, selectedPhotoIds]);
const toggleIsSelectingAllPhotos = useCallback(() => { const toggleIsSelectingAllPhotos = useCallback(() => {
setIsSelectingAllPhotos(prev => !prev); setIsSelectingAllPhotos(!isSelectingAllPhotos);
setSelectedPhotoIds([]); setSelectedPhotoIds([]);
}, []); if (!isSelectingAllPhotos) {
getPhotoOptionsCountForPathAction(pathname)
.then(({ options, count }) => {
setSelectAllPhotoOptions(options);
setSelectAllCount(count);
})
.catch(() => setIsSelectingAllPhotos(false));
}
}, [isSelectingAllPhotos, pathname]);
useEffect(() => { useEffect(() => {
if (isSelectingPhotos) { if (isSelectingPhotos) {
@ -94,6 +116,8 @@ export default function SelectPhotosProvider({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedPhotoIds([]); setSelectedPhotoIds([]);
setIsSelectingAllPhotos(false); setIsSelectingAllPhotos(false);
setSelectAllPhotoOptions(undefined);
setSelectAllCount(undefined);
} }
}, [isSelectingPhotos, getPhotoGridElements]); }, [isSelectingPhotos, getPhotoGridElements]);
@ -102,10 +126,13 @@ export default function SelectPhotosProvider({
canCurrentPageSelectPhotos, canCurrentPageSelectPhotos,
isSelectingPhotos, isSelectingPhotos,
isSelectingAllPhotos, isSelectingAllPhotos,
shouldShowSelectAll,
toggleIsSelectingAllPhotos, toggleIsSelectingAllPhotos,
startSelectingPhotos, startSelectingPhotos,
stopSelectingPhotos, stopSelectingPhotos,
selectedPhotoIds, selectedPhotoIds,
selectAllPhotoOptions,
selectAllCount,
togglePhotoSelection, togglePhotoSelection,
isPerformingSelectEdit, isPerformingSelectEdit,
setIsPerformingSelectEdit, setIsPerformingSelectEdit,

View File

@ -1,13 +1,17 @@
import { PhotoQueryOptions } from '@/db';
import { createContext, Dispatch, SetStateAction, use } from 'react'; import { createContext, Dispatch, SetStateAction, use } from 'react';
export type SelectPhotosState = { export type SelectPhotosState = {
canCurrentPageSelectPhotos?: boolean canCurrentPageSelectPhotos?: boolean
isSelectingPhotos?: boolean isSelectingPhotos?: boolean
isSelectingAllPhotos?: boolean isSelectingAllPhotos?: boolean
shouldShowSelectAll?: boolean
toggleIsSelectingAllPhotos?: () => void toggleIsSelectingAllPhotos?: () => void
startSelectingPhotos?: () => void startSelectingPhotos?: () => void
stopSelectingPhotos?: () => void stopSelectingPhotos?: () => void
selectedPhotoIds?: string[] selectedPhotoIds?: string[]
selectAllPhotoOptions?: PhotoQueryOptions
selectAllCount?: number
togglePhotoSelection?: (photoId: string) => void togglePhotoSelection?: (photoId: string) => void
isPerformingSelectEdit?: boolean isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>> setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>

View File

@ -42,6 +42,8 @@ export const PATH_FEED_JSON = '/feed.json';
// Path prefixes // Path prefixes
export const PREFIX_PHOTO = '/p'; export const PREFIX_PHOTO = '/p';
export const PREFIX_RECENTS = '/recents';
export const PREFIX_YEAR = '/year';
export const PREFIX_CAMERA = '/shot-on'; export const PREFIX_CAMERA = '/shot-on';
export const PREFIX_LENS = '/lens'; export const PREFIX_LENS = '/lens';
export const PREFIX_ALBUM = '/album'; export const PREFIX_ALBUM = '/album';
@ -49,20 +51,18 @@ export const PREFIX_TAG = '/tag';
export const PREFIX_RECIPE = '/recipe'; export const PREFIX_RECIPE = '/recipe';
export const PREFIX_FILM = '/film'; export const PREFIX_FILM = '/film';
export const PREFIX_FOCAL_LENGTH = '/focal'; export const PREFIX_FOCAL_LENGTH = '/focal';
export const PREFIX_YEAR = '/year';
export const PREFIX_RECENTS = '/recents';
// Dynamic paths // Dynamic paths
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; 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_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`; const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`;
const PATH_ALBUM_DYNAMIC = `${PREFIX_ALBUM}/[album]`; const PATH_ALBUM_DYNAMIC = `${PREFIX_ALBUM}/[album]`;
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`; 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_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
const PATH_YEAR_DYNAMIC = `${PREFIX_YEAR}/[year]`; const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
const PATH_RECENTS_DYNAMIC = `${PREFIX_RECENTS}/[photoId]`;
// Admin paths // Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
@ -467,57 +467,81 @@ export const getPathComponents = (
}) => { }) => {
const photoIdFromPhoto = pathname.match( const photoIdFromPhoto = pathname.match(
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; 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( const photoIdFromCamera = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1];
const cameraMake = pathname.match( const cameraMake = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1];
const cameraModel = pathname.match( const cameraModel = pathname.match(
new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1]; 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( const photoIdFromTag = pathname.match(
new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1];
const photoIdFromRecipe = pathname.match(
new RegExp(`^${PREFIX_RECIPE}/[^/]+/([^/]+)`))?.[1];
const photoIdFromFilm = pathname.match( const photoIdFromFilm = pathname.match(
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
const photoIdFromFocalLength = pathname.match( const photoIdFromFocalLength = pathname.match(
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
const photoIdFromYear = pathname.match( const photoIdFromYear = pathname.match(
new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_YEAR}/[^/]+/([^/]+)`))?.[1];
const photoIdFromRecents = pathname.match(
new RegExp(`^${PREFIX_RECENTS}/([^/]+)`))?.[1];
const album = pathname.match( const album = pathname.match(
new RegExp(`^${PREFIX_ALBUM}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_ALBUM}/([^/]+)`))?.[1];
const tag = pathname.match( const tag = pathname.match(
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
const recipe = pathname.match(
new RegExp(`^${PREFIX_RECIPE}/([^/]+)`))?.[1];
const film = pathname.match( const film = pathname.match(
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string; new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
const focalString = pathname.match( const focalString = pathname.match(
new RegExp(`^${PREFIX_FOCAL_LENGTH}/([0-9]+)mm`))?.[1]; 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 const camera = cameraMake && cameraModel
? { make: cameraMake, model: cameraModel } ? { make: cameraMake, model: cameraModel }
: undefined; : undefined;
const lens = lensMake && lensModel
? { make: lensMake, model: lensModel }
: undefined;
const focal = focalString ? parseInt(focalString) : undefined; const focal = focalString ? parseInt(focalString) : undefined;
return { return {
photoId: ( photoId: (
photoIdFromPhoto || photoIdFromPhoto ||
photoIdFromTag || photoIdFromRecents ||
photoIdFromCamera ||
photoIdFromFilm ||
photoIdFromFocalLength ||
photoIdFromYear || photoIdFromYear ||
photoIdFromRecents photoIdFromCamera ||
photoIdFromLens ||
photoIdFromAlbum ||
photoIdFromTag ||
photoIdFromRecipe ||
photoIdFromFilm ||
photoIdFromFocalLength
), ),
recent,
year,
camera,
lens,
album, album,
tag, tag,
camera, recipe,
film, film,
focal, focal,
year,
recent,
}; };
}; };
@ -541,6 +565,7 @@ export const getEscapePath = (pathname?: string) => {
(year && isPathYear(pathname)) || (year && isPathYear(pathname)) ||
(camera && isPathCamera(pathname)) || (camera && isPathCamera(pathname)) ||
(lens && isPathLens(pathname)) || (lens && isPathLens(pathname)) ||
(album && isPathAlbum(pathname)) ||
(tag && isPathTag(pathname)) || (tag && isPathTag(pathname)) ||
(film && isPathFilm(pathname)) || (film && isPathFilm(pathname)) ||
(focal && isPathFocalLength(pathname)) || (focal && isPathFocalLength(pathname)) ||

View File

@ -4,6 +4,10 @@ import { Camera } from '@/camera';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
import { APP_DEFAULT_SORT_BY, SortBy } from '@/photo/sort'; import { APP_DEFAULT_SORT_BY, SortBy } from '@/photo/sort';
import { Album } from '@/album'; 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 GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
@ -254,3 +258,30 @@ export const generateManyToManyValues = (idsA: string[], idsB: string[]) => {
values, 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,
};
};

View File

@ -16,7 +16,11 @@ import {
getColorDataForPhotos, getColorDataForPhotos,
getPhotoIds, getPhotoIds,
} from '@/photo/query'; } from '@/photo/query';
import { PhotoQueryOptions, areOptionsSensitive } from '@/db'; import {
PhotoQueryOptions,
areOptionsSensitive,
getPhotoOptionsCountForPath,
} from '@/db';
import { import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC, FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData, PhotoFormData,
@ -702,6 +706,11 @@ export const getImageBlurAction = async (url: string) =>
// Batch actions // Batch actions
export const getPhotoOptionsCountForPathAction = async (path: string) =>
runAuthenticatedAdminServerAction(async () =>
getPhotoOptionsCountForPath(path),
);
export const batchPhotoAction = async ({ export const batchPhotoAction = async ({
photoIds: _photoIds = [], photoIds: _photoIds = [],
photoOptions, photoOptions,

View File

@ -294,16 +294,16 @@ export const getUniqueLenses = async () =>
}))) })))
, 'getUniqueLenses'); , 'getUniqueLenses');
export const getUniqueTags = async () => export const getUniqueTags = async (includeHidden?: boolean) =>
safelyQuery(() => sql` safelyQuery(() => query(`
SELECT DISTINCT unnest(tags) as tag, SELECT DISTINCT unnest(tags) as tag,
COUNT(*), COUNT(*),
MAX(updated_at) as last_modified MAX(updated_at) as last_modified
FROM photos FROM photos
WHERE hidden IS NOT TRUE ${includeHidden ? '' : 'WHERE hidden IS NOT TRUE'}
GROUP BY tag GROUP BY tag
ORDER BY tag ASC ORDER BY tag ASC
`.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({ `).then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
tag, tag,
count: parseInt(count, 10), count: parseInt(count, 10),
lastModified: last_modified as Date, lastModified: last_modified as Date,
@ -422,8 +422,13 @@ export const getUniqueFocalLengths = async () =>
const _getPhotos = async ( const _getPhotos = async (
options: PhotoQueryOptions = {}, options: PhotoQueryOptions = {},
fields = ['*'], fields = ['*'], {
shouldParse = true, shouldParse = true,
includeOrderBy = true,
}: {
shouldParse?: boolean,
includeOrderBy?: boolean,
} = {},
) => { ) => {
const sql = [ const sql = [
`SELECT ${fields.map(field => `p.${field}`).join(', ')} FROM photos p`, `SELECT ${fields.map(field => `p.${field}`).join(', ')} FROM photos p`,
@ -446,7 +451,9 @@ const _getPhotos = async (
values.push(...wheresValues); values.push(...wheresValues);
} }
if (includeOrderBy) {
sql.push(getOrderByFromOptions(options)); sql.push(getOrderByFromOptions(options));
}
const { const {
limitAndOffset, limitAndOffset,
@ -478,7 +485,7 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) =>
export const getPhotoIds = async (options: PhotoQueryOptions = {}) => export const getPhotoIds = async (options: PhotoQueryOptions = {}) =>
safelyQuery( safelyQuery(
async () => _getPhotos(options, ['id'], false) async () => _getPhotos(options, ['id'], { shouldParse: false })
.then(({ photos }) => photos.map(photo => photo.id)), .then(({ photos }) => photos.map(photo => photo.id)),
'getPhotoIds', 'getPhotoIds',
// Seemingly necessary to pass `options` for expected cache behavior // 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 = {}) => export const getPhotoUrls = async (options: PhotoQueryOptions = {}) =>
safelyQuery( safelyQuery(
async () => _getPhotos(options, ['id', 'title', 'url', 'hidden'], false) async () => _getPhotos(
options,
['id', 'title', 'url', 'hidden'],
{ shouldParse: false },
)
.then(({ photos }) => .then(({ photos }) =>
photos as { photos as {
id: string, id: string,
@ -502,7 +513,11 @@ export const getPhotoUrls = async (options: PhotoQueryOptions = {}) =>
export const getPhotoCount = async (options: PhotoQueryOptions = {}) => export const getPhotoCount = async (options: PhotoQueryOptions = {}) =>
safelyQuery( safelyQuery(
async () => _getPhotos(options, ['COUNT(*)'], false) async () => _getPhotos(
options,
['COUNT'],
{ shouldParse: false, includeOrderBy: false },
)
.then(({ count }) => count), .then(({ count }) => count),
'getPhotoCount', 'getPhotoCount',
// Seemingly necessary to pass `options` for expected cache behavior // Seemingly necessary to pass `options` for expected cache behavior

View File

@ -147,7 +147,8 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
export const isPathFavs = (pathname?: string) => export const isPathFavs = (pathname?: string) =>
getPathComponents(pathname).tag === TAG_FAVS; 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 = ( export const addPrivateToTags = (
tags: Tags, tags: Tags,