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 {
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 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_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_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);

View File

@ -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); }

View File

@ -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

View File

@ -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),

View File

@ -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(() => {

View File

@ -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,9 +73,17 @@ export default function AdminBatchEditPanelClient({
const isFormDisabled =
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 />
<ResponsiveText shortText="Select">
@ -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,25 +215,21 @@ 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
>
<div className="flex items-center gap-2 [&>*:first-child]:grow">
{isInAlbumMode
? <FieldsetAlbum
albumOptions={uniqueAlbums}
@ -235,19 +250,23 @@ export default function AdminBatchEditPanelClient({
openOnLoad
hideLabel
/>
: <div>
<div className="text-base flex gap-2 items-center">
{renderPhotoCTA}
: <div className="grow">
<div className="flex items-center gap-2">
{renderPhotoSelectionStatus}
</div>
{showSelectAll &&
</div>}
{renderActions}
</div>
{shouldShowSelectAll &&
<FieldsetWithStatus
label="Select All Photos"
label="Select All"
type="checkbox"
className="-z-10"
value={isSelectingAllPhotos ? 'true' : 'false'}
onChange={toggleIsSelectingAllPhotos}
readOnly={isSelectingAllPhotos &&
selectAllCount === undefined}
/>}
</div>}
</Note>
{tagErrorMessage &&
<div className="text-error pl-4">
{tagErrorMessage}

View File

@ -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,

View File

@ -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>>

View File

@ -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)) ||

View File

@ -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,
};
};

View File

@ -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,

View File

@ -294,16 +294,16 @@ 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 }) => ({
`).then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
tag,
count: parseInt(count, 10),
lastModified: last_modified as Date,
@ -422,8 +422,13 @@ export const getUniqueFocalLengths = async () =>
const _getPhotos = async (
options: PhotoQueryOptions = {},
fields = ['*'],
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);
}
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

View File

@ -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,