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 {
|
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 FILM = 'acros';
|
const LENS_MAKE = 'fujifilm';
|
||||||
const FOCAL_LENGTH = 90;
|
const LENS_MODEL = 'xf90mmf2-r-lm-wr';
|
||||||
const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
|
const LENS_OBJECT = { make: LENS_MAKE, model: LENS_MODEL };
|
||||||
|
const ALBUM = 'album-name';
|
||||||
const PATH_ROOT = '/';
|
const TAG = 'tag-name';
|
||||||
const PATH_GRID = '/grid';
|
const RECIPE = 'nature-nurture';
|
||||||
const PATH_FULL = '/full';
|
const FILM = 'acros';
|
||||||
const PATH_ADMIN = '/admin/photos';
|
const FOCAL_LENGTH = 90;
|
||||||
const PATH_OG = '/og';
|
const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`;
|
||||||
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);
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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,18 +73,26 @@ 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
|
||||||
<FaArrowDown />
|
? 'Selecting ...'
|
||||||
<ResponsiveText shortText="Select">
|
: <ResponsiveText shortText={`${selectAllCount} selected`}>
|
||||||
Select photos below
|
{`${selectAllCount} photos selected`}
|
||||||
</ResponsiveText>
|
</ResponsiveText>
|
||||||
</>
|
: selectedPhotoIds?.length === 0
|
||||||
: <ResponsiveText shortText={photosText}>
|
? <>
|
||||||
{photosText} selected
|
<FaArrowDown />
|
||||||
</ResponsiveText>;
|
<ResponsiveText shortText="Select">
|
||||||
|
Select photos below
|
||||||
|
</ResponsiveText>
|
||||||
|
</>
|
||||||
|
: <ResponsiveText shortText={photosText}>
|
||||||
|
{photosText} selected
|
||||||
|
</ResponsiveText>;
|
||||||
|
|
||||||
const renderActions = isInTagMode || isInAlbumMode
|
const renderActions = isInTagMode || isInAlbumMode
|
||||||
? <>
|
? <>
|
||||||
@ -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,62 +215,62 @@ 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
|
|
||||||
>
|
>
|
||||||
{isInAlbumMode
|
<div className="flex items-center gap-2 [&>*:first-child]:grow">
|
||||||
? <FieldsetAlbum
|
{isInAlbumMode
|
||||||
albumOptions={uniqueAlbums}
|
? <FieldsetAlbum
|
||||||
value={albumTitles}
|
albumOptions={uniqueAlbums}
|
||||||
onChange={setAlbumsTitles}
|
value={albumTitles}
|
||||||
readOnly={isPerformingSelectEdit}
|
onChange={setAlbumsTitles}
|
||||||
openOnLoad
|
|
||||||
hideLabel
|
|
||||||
/>
|
|
||||||
: isInTagMode
|
|
||||||
? <FieldsetTag
|
|
||||||
tags={tags}
|
|
||||||
tagOptions={uniqueTags}
|
|
||||||
placeholder={`Tag ${photosText} ...`}
|
|
||||||
onChange={setTags}
|
|
||||||
onError={setTagErrorMessage}
|
|
||||||
readOnly={isPerformingSelectEdit}
|
readOnly={isPerformingSelectEdit}
|
||||||
openOnLoad
|
openOnLoad
|
||||||
hideLabel
|
hideLabel
|
||||||
/>
|
/>
|
||||||
: <div>
|
: isInTagMode
|
||||||
<div className="text-base flex gap-2 items-center">
|
? <FieldsetTag
|
||||||
{renderPhotoCTA}
|
tags={tags}
|
||||||
</div>
|
tagOptions={uniqueTags}
|
||||||
{showSelectAll &&
|
placeholder={`Tag ${photosText} ...`}
|
||||||
<FieldsetWithStatus
|
onChange={setTags}
|
||||||
label="Select All Photos"
|
onError={setTagErrorMessage}
|
||||||
type="checkbox"
|
readOnly={isPerformingSelectEdit}
|
||||||
value={isSelectingAllPhotos ? 'true' : 'false'}
|
openOnLoad
|
||||||
onChange={toggleIsSelectingAllPhotos}
|
hideLabel
|
||||||
/>}
|
/>
|
||||||
</div>}
|
: <div className="grow">
|
||||||
</Note>
|
<div className="flex items-center gap-2">
|
||||||
{tagErrorMessage &&
|
{renderPhotoSelectionStatus}
|
||||||
<div className="text-error pl-4">
|
</div>
|
||||||
{tagErrorMessage}
|
</div>}
|
||||||
</div>}
|
{renderActions}
|
||||||
</div>} />
|
</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;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>>
|
||||||
|
|||||||
@ -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)) ||
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -294,20 +294,20 @@ 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,
|
||||||
})))
|
})))
|
||||||
, 'getUniqueTags');
|
, 'getUniqueTags');
|
||||||
|
|
||||||
export const getUniqueRecipes = async () =>
|
export const getUniqueRecipes = async () =>
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.push(getOrderByFromOptions(options));
|
if (includeOrderBy) {
|
||||||
|
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
|
||||||
|
|||||||
@ -19,8 +19,8 @@ import { CategoryQueryMeta, sortCategoryByCount } from '@/category';
|
|||||||
import { AppTextState } from '@/i18n/state';
|
import { AppTextState } from '@/i18n/state';
|
||||||
|
|
||||||
// Reserved tags
|
// Reserved tags
|
||||||
export const TAG_FAVS = 'favs';
|
export const TAG_FAVS = 'favs';
|
||||||
export const TAG_PRIVATE = 'private';
|
export const TAG_PRIVATE = 'private';
|
||||||
|
|
||||||
type TagWithMeta = { tag: string } & CategoryQueryMeta;
|
type TagWithMeta = { tag: string } & CategoryQueryMeta;
|
||||||
|
|
||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user