diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index de22a4f2..cc13e7a4 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import { getEscapePath, getPathComponents, @@ -11,41 +12,71 @@ import { isPathProtected, isPathTag, isPathTagPhoto, + PATH_ADMIN, + PATH_ADMIN_PHOTOS, + PATH_FULL, + PATH_GRID, + PATH_OG, + PATH_OG_ALL, + PATH_OG_SAMPLE, + PATH_ROOT, + PREFIX_ALBUM, + PREFIX_CAMERA, + PREFIX_FILM, + PREFIX_FOCAL_LENGTH, + PREFIX_LENS, + PREFIX_RECENTS, + PREFIX_RECIPE, + PREFIX_TAG, + PREFIX_YEAR, } from '@/app/path'; import { TAG_PRIVATE } from '@/tag'; -const PHOTO_ID = 'UsKSGcbt'; -const TAG = 'tag-name'; -const CAMERA_MAKE = 'fujifilm'; -const CAMERA_MODEL = 'x-t1'; -const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL }; -const FILM = 'acros'; -const FOCAL_LENGTH = 90; -const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`; - -const PATH_ROOT = '/'; -const PATH_GRID = '/grid'; -const PATH_FULL = '/full'; -const PATH_ADMIN = '/admin/photos'; -const PATH_OG = '/og'; -const PATH_OG_ALL = `${PATH_OG}/all`; -const PATH_OG_SAMPLE = `${PATH_OG}/sample`; +const PHOTO_ID = 'UsKSGcbt'; +const YEAR = '2025'; +const CAMERA_MAKE = 'fujifilm'; +const CAMERA_MODEL = 'x-t1'; +const CAMERA_OBJECT = { make: CAMERA_MAKE, model: CAMERA_MODEL }; +const LENS_MAKE = 'fujifilm'; +const LENS_MODEL = 'xf90mmf2-r-lm-wr'; +const LENS_OBJECT = { make: LENS_MAKE, model: LENS_MODEL }; +const ALBUM = 'album-name'; +const TAG = 'tag-name'; +const RECIPE = 'nature-nurture'; +const FILM = 'acros'; +const FOCAL_LENGTH = 90; +const FOCAL_LENGTH_STRING = `${FOCAL_LENGTH}mm`; const PATH_PHOTO = `/p/${PHOTO_ID}`; -const PATH_TAG = `/tag/${TAG}`; -const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; +const PATH_RECENTS = PREFIX_RECENTS; +const PATH_RECENTS_PHOTO = `${PATH_RECENTS}/${PHOTO_ID}`; -const PATH_TAG_PRIVATE = `/tag/${TAG_PRIVATE}`; -const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`; +const PATH_YEAR = `${PREFIX_YEAR}/${YEAR}`; +const PATH_YEAR_PHOTO = `${PATH_YEAR}/${PHOTO_ID}`; -const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`; +const PATH_CAMERA = `${PREFIX_CAMERA}/${CAMERA_MAKE}/${CAMERA_MODEL}`; const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`; -const PATH_FILM = `/film/${FILM}`; +const PATH_LENS = `${PREFIX_LENS}/${LENS_MAKE}/${LENS_MODEL}`; +const PATH_LENS_PHOTO = `${PATH_LENS}/${PHOTO_ID}`; + +const PATH_ALBUM = `${PREFIX_ALBUM}/${ALBUM}`; +const PATH_ALBUM_PHOTO = `${PATH_ALBUM}/${PHOTO_ID}`; + +const PATH_TAG = `${PREFIX_TAG}/${TAG}`; +const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; + +const PATH_TAG_PRIVATE = `${PREFIX_TAG}/${TAG_PRIVATE}`; +const PATH_TAG_PRIVATE_PHOTO = `${PATH_TAG_PRIVATE}/${PHOTO_ID}`; + +const PATH_RECIPE = `${PREFIX_RECIPE}/${RECIPE}`; +const PATH_RECIPE_PHOTO = `${PATH_RECIPE}/${PHOTO_ID}`; + +const PATH_FILM = `${PREFIX_FILM}/${FILM}`; const PATH_FILM_PHOTO = `${PATH_FILM}/${PHOTO_ID}`; -const PATH_FOCAL_LENGTH = `/focal/${FOCAL_LENGTH_STRING}`; +const PATH_FOCAL_LENGTH = `${PREFIX_FOCAL_LENGTH}/${FOCAL_LENGTH_STRING}`; const PATH_FOCAL_LENGTH_PHOTO = `${PATH_FOCAL_LENGTH}/${PHOTO_ID}`; describe('Paths', () => { @@ -59,6 +90,7 @@ describe('Paths', () => { expect(isPathProtected(PATH_FILM)).toBe(false); // Private expect(isPathProtected(PATH_ADMIN)).toBe(true); + expect(isPathProtected(PATH_ADMIN_PHOTOS)).toBe(true); expect(isPathProtected(PATH_OG)).toBe(true); expect(isPathProtected(PATH_OG_ALL)).toBe(true); expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true); @@ -68,10 +100,10 @@ describe('Paths', () => { it('can be classified', () => { // Positive expect(isPathPhoto(PATH_PHOTO)).toBe(true); - expect(isPathTag(PATH_TAG)).toBe(true); - expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true); expect(isPathCamera(PATH_CAMERA)).toBe(true); expect(isPathCameraPhoto(PATH_CAMERA_PHOTO)).toBe(true); + expect(isPathTag(PATH_TAG)).toBe(true); + expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true); expect(isPathFilm(PATH_FILM)).toBe(true); expect(isPathFilmPhoto(PATH_FILM_PHOTO)).toBe(true); expect(isPathFocalLength(PATH_FOCAL_LENGTH)).toBe(true); @@ -86,13 +118,21 @@ describe('Paths', () => { expect(getPathComponents(PATH_PHOTO)).toEqual({ photoId: PHOTO_ID, }); - // Tag - expect(getPathComponents(PATH_TAG)).toEqual({ - tag: TAG, + // Recents + expect(getPathComponents(PATH_RECENTS)).toEqual({ + recent: true, }); - expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({ + expect(getPathComponents(PATH_RECENTS_PHOTO)).toEqual({ photoId: PHOTO_ID, - tag: TAG, + recent: true, + }); + // Year + expect(getPathComponents(PATH_YEAR)).toEqual({ + year: YEAR, + }); + expect(getPathComponents(PATH_YEAR_PHOTO)).toEqual({ + photoId: PHOTO_ID, + year: YEAR, }); // Camera expect(getPathComponents(PATH_CAMERA)).toEqual({ @@ -102,6 +142,38 @@ describe('Paths', () => { photoId: PHOTO_ID, camera: CAMERA_OBJECT, }); + // Lens + expect(getPathComponents(PATH_LENS)).toEqual({ + lens: LENS_OBJECT, + }); + expect(getPathComponents(PATH_LENS_PHOTO)).toEqual({ + photoId: PHOTO_ID, + lens: LENS_OBJECT, + }); + // Album + expect(getPathComponents(PATH_ALBUM)).toEqual({ + album: ALBUM, + }); + expect(getPathComponents(PATH_ALBUM_PHOTO)).toEqual({ + photoId: PHOTO_ID, + album: ALBUM, + }); + // Tag + expect(getPathComponents(PATH_TAG)).toEqual({ + tag: TAG, + }); + expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({ + photoId: PHOTO_ID, + tag: TAG, + }); + // Recipe + expect(getPathComponents(PATH_RECIPE)).toEqual({ + recipe: RECIPE, + }); + expect(getPathComponents(PATH_RECIPE_PHOTO)).toEqual({ + photoId: PHOTO_ID, + recipe: RECIPE, + }); // Film expect(getPathComponents(PATH_FILM)).toEqual({ film: FILM, @@ -127,12 +199,27 @@ describe('Paths', () => { expect(getEscapePath(PATH_ADMIN)).toEqual(undefined); // Photo expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_ROOT); - // Tag - expect(getEscapePath(PATH_TAG)).toEqual(PATH_ROOT); - expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG); + // Recents + expect(getEscapePath(PATH_RECENTS)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_RECENTS_PHOTO)).toEqual(PATH_RECENTS); + // Year + expect(getEscapePath(PATH_YEAR)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_YEAR_PHOTO)).toEqual(PATH_YEAR); // Camera expect(getEscapePath(PATH_CAMERA)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_CAMERA_PHOTO)).toEqual(PATH_CAMERA); + // Lens + expect(getEscapePath(PATH_LENS)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_LENS_PHOTO)).toEqual(PATH_LENS); + // Album + expect(getEscapePath(PATH_ALBUM)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_ALBUM_PHOTO)).toEqual(PATH_ALBUM); + // Tag + expect(getEscapePath(PATH_TAG)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG); + // Recipe + expect(getEscapePath(PATH_RECIPE)).toEqual(PATH_ROOT); + expect(getEscapePath(PATH_RECIPE_PHOTO)).toEqual(PATH_RECIPE); // Film expect(getEscapePath(PATH_FILM)).toEqual(PATH_ROOT); expect(getEscapePath(PATH_FILM_PHOTO)).toEqual(PATH_FILM); diff --git a/app/admin/tags/[tag]/edit/page.tsx b/app/admin/tags/[tag]/edit/page.tsx index c5fa373e..2e62c755 100644 --- a/app/admin/tags/[tag]/edit/page.tsx +++ b/app/admin/tags/[tag]/edit/page.tsx @@ -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); } diff --git a/app/admin/tags/page.tsx b/app/admin/tags/page.tsx index c4dfe19d..8e349219 100644 --- a/app/admin/tags/page.tsx +++ b/app/admin/tags/page.tsx @@ -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 ( albums.length) .catch(() => 0), - getUniqueTagsCached().then(tags => tags.length) + getUniqueTagsCached(true).then(tags => tags.length) .catch(() => 0), getUniqueRecipesCached().then(recipes => recipes.length) .catch(() => 0), diff --git a/src/admin/DeletePhotosButton.tsx b/src/admin/DeletePhotosButton.tsx index fe5761c9..55c54536 100644 --- a/src/admin/DeletePhotosButton.tsx +++ b/src/admin/DeletePhotosButton.tsx @@ -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) { 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(() => { diff --git a/src/admin/select/AdminBatchEditPanelClient.tsx b/src/admin/select/AdminBatchEditPanelClient.tsx index f6d48b89..86e49c8a 100644 --- a/src/admin/select/AdminBatchEditPanelClient.tsx +++ b/src/admin/select/AdminBatchEditPanelClient.tsx @@ -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(null); const { canCurrentPageSelectPhotos, + shouldShowSelectAll, isSelectingPhotos, stopSelectingPhotos, isSelectingAllPhotos, toggleIsSelectingAllPhotos, selectedPhotoIds, + selectAllPhotoOptions, + selectAllCount, isPerformingSelectEdit, setIsPerformingSelectEdit, } = useSelectPhotosState(); @@ -55,8 +55,17 @@ export default function AdminBatchEditPanelClient({ const [tagErrorMessage, setTagErrorMessage] = useState(''); const isInTagMode = tags !== undefined; + const batchPhotoActionArguments = ( + isSelectingAllPhotos && + selectAllPhotoOptions + ) + ? { photoOptions: selectAllPhotoOptions } + : { photoIds: selectedPhotoIds }; + const photosText = photoQuantityText( - selectedPhotoIds?.length ?? 0, + (isSelectingAllPhotos && selectAllCount !== undefined + ? selectAllCount + : selectedPhotoIds?.length) ?? 0, appText, false, false, @@ -64,18 +73,26 @@ export default function AdminBatchEditPanelClient({ const isFormDisabled = isPerformingSelectEdit || - selectedPhotoIds?.length === 0; + isSelectingAllPhotos + ? !Boolean(selectAllCount) + : selectedPhotoIds?.length === 0; - const renderPhotoCTA = selectedPhotoIds?.length === 0 - ? <> - - - Select photos below + const renderPhotoSelectionStatus = isSelectingAllPhotos + ? selectAllCount === undefined + ? 'Selecting ...' + : + {`${selectAllCount} photos selected`} - - : - {photosText} selected - ; + : selectedPhotoIds?.length === 0 + ? <> + + + Select photos below + + + : + {photosText} selected + ; const renderActions = isInTagMode || isInAlbumMode ? <> @@ -104,7 +121,7 @@ export default function AdminBatchEditPanelClient({ setIsPerformingSelectEdit?.(true); if (isInTagMode) { batchPhotoAction({ - photoIds: selectedPhotoIds, + ...batchPhotoActionArguments, tags: convertStringToArray(tags, false), }) .then(() => { @@ -114,7 +131,7 @@ export default function AdminBatchEditPanelClient({ .finally(() => setIsPerformingSelectEdit?.(false)); } else if (isInAlbumMode) { batchPhotoAction({ - photoIds: selectedPhotoIds, + ...batchPhotoActionArguments, albumTitles: albumTitles.split(','), }) .then(() => { @@ -129,8 +146,7 @@ export default function AdminBatchEditPanelClient({ (!tags || Boolean(tagErrorMessage)) && !albumTitles ) || - (selectedPhotoIds?.length ?? 0) === 0 || - isPerformingSelectEdit + isFormDisabled } primary > @@ -139,7 +155,10 @@ export default function AdminBatchEditPanelClient({ : <> setIsPerformingSelectEdit?.(true)} onDelete={stopSelectingPhotos} @@ -152,7 +171,7 @@ export default function AdminBatchEditPanelClient({ onClick={() => { setIsPerformingSelectEdit?.(true); batchPhotoAction({ - photoIds: selectedPhotoIds, + ...batchPhotoActionArguments, action: 'favorite', }) .then(() => { @@ -196,62 +215,62 @@ export default function AdminBatchEditPanelClient({ return shouldShowPanel ? - 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={
- {renderActions} -
} - spaceChildren={false} - hideIcon > - {isInAlbumMode - ? - : isInTagMode - ? + {isInAlbumMode + ? - :
-
- {renderPhotoCTA} -
- {showSelectAll && - } -
} -
- {tagErrorMessage && -
- {tagErrorMessage} -
} - } /> + : isInTagMode + ? + :
+
+ {renderPhotoSelectionStatus} +
+
} + {renderActions} + + {shouldShowSelectAll && + } + {tagErrorMessage && +
+ {tagErrorMessage} +
} + } /> : null; } diff --git a/src/admin/select/SelectPhotosProvider.tsx b/src/admin/select/SelectPhotosProvider.tsx index 781855d1..4fd78523 100644 --- a/src/admin/select/SelectPhotosProvider.tsx +++ b/src/admin/select/SelectPhotosProvider.tsx @@ -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([]); const [isSelectingAllPhotos, setIsSelectingAllPhotos] = useState(false); + const [selectAllPhotoOptions, setSelectAllPhotoOptions] = + useState(); + const [selectAllCount, setSelectAllCount] = useState(); 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, diff --git a/src/admin/select/SelectPhotosState.ts b/src/admin/select/SelectPhotosState.ts index 3cc9827a..d7b435cf 100644 --- a/src/admin/select/SelectPhotosState.ts +++ b/src/admin/select/SelectPhotosState.ts @@ -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> diff --git a/src/app/path.ts b/src/app/path.ts index 7822dc8f..29d4e5b6 100644 --- a/src/app/path.ts +++ b/src/app/path.ts @@ -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)) || diff --git a/src/db/index.ts b/src/db/index.ts index c69edbb6..c13c8d6a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -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, + }; +}; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 94711caf..d38d31ff 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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, diff --git a/src/photo/query.ts b/src/photo/query.ts index 3698f579..68bb37b5 100644 --- a/src/photo/query.ts +++ b/src/photo/query.ts @@ -294,20 +294,20 @@ export const getUniqueLenses = async () => }))) , 'getUniqueLenses'); -export const getUniqueTags = async () => - safelyQuery(() => sql` +export const getUniqueTags = async (includeHidden?: boolean) => + safelyQuery(() => query(` SELECT DISTINCT unnest(tags) as tag, COUNT(*), MAX(updated_at) as last_modified FROM photos - WHERE hidden IS NOT TRUE + ${includeHidden ? '' : 'WHERE hidden IS NOT TRUE'} GROUP BY tag ORDER BY tag ASC - `.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({ - tag, - count: parseInt(count, 10), - lastModified: last_modified as Date, - }))) + `).then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({ + tag, + count: parseInt(count, 10), + lastModified: last_modified as Date, + }))) , 'getUniqueTags'); export const getUniqueRecipes = async () => @@ -422,8 +422,13 @@ export const getUniqueFocalLengths = async () => const _getPhotos = async ( options: PhotoQueryOptions = {}, - fields = ['*'], - shouldParse = true, + fields = ['*'], { + shouldParse = true, + includeOrderBy = true, + }: { + shouldParse?: boolean, + includeOrderBy?: boolean, + } = {}, ) => { const sql = [ `SELECT ${fields.map(field => `p.${field}`).join(', ')} FROM photos p`, @@ -446,7 +451,9 @@ const _getPhotos = async ( values.push(...wheresValues); } - sql.push(getOrderByFromOptions(options)); + if (includeOrderBy) { + sql.push(getOrderByFromOptions(options)); + } const { limitAndOffset, @@ -478,7 +485,7 @@ export const getPhotos = async (options: PhotoQueryOptions = {}) => export const getPhotoIds = async (options: PhotoQueryOptions = {}) => safelyQuery( - async () => _getPhotos(options, ['id'], false) + async () => _getPhotos(options, ['id'], { shouldParse: false }) .then(({ photos }) => photos.map(photo => photo.id)), 'getPhotoIds', // Seemingly necessary to pass `options` for expected cache behavior @@ -487,7 +494,11 @@ export const getPhotoIds = async (options: PhotoQueryOptions = {}) => export const getPhotoUrls = async (options: PhotoQueryOptions = {}) => safelyQuery( - async () => _getPhotos(options, ['id', 'title', 'url', 'hidden'], false) + async () => _getPhotos( + options, + ['id', 'title', 'url', 'hidden'], + { shouldParse: false }, + ) .then(({ photos }) => photos as { id: string, @@ -502,7 +513,11 @@ export const getPhotoUrls = async (options: PhotoQueryOptions = {}) => export const getPhotoCount = async (options: PhotoQueryOptions = {}) => safelyQuery( - async () => _getPhotos(options, ['COUNT(*)'], false) + async () => _getPhotos( + options, + ['COUNT'], + { shouldParse: false, includeOrderBy: false }, + ) .then(({ count }) => count), 'getPhotoCount', // Seemingly necessary to pass `options` for expected cache behavior diff --git a/src/tag/index.ts b/src/tag/index.ts index f1a0abf7..37ed1465 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -19,8 +19,8 @@ import { CategoryQueryMeta, sortCategoryByCount } from '@/category'; import { AppTextState } from '@/i18n/state'; // Reserved tags -export const TAG_FAVS = 'favs'; -export const TAG_PRIVATE = 'private'; +export const TAG_FAVS = 'favs'; +export const TAG_PRIVATE = 'private'; type TagWithMeta = { tag: string } & CategoryQueryMeta; @@ -147,7 +147,8 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs); export const isPathFavs = (pathname?: string) => getPathComponents(pathname).tag === TAG_FAVS; -export const isTagPrivate = (tag: string) => tag.toLowerCase() === TAG_PRIVATE; +export const isTagPrivate = (tag = '') => + tag.toLocaleLowerCase() === TAG_PRIVATE; export const addPrivateToTags = ( tags: Tags,