From c0f4f1fbf1df7f170c78bcb36cda0938ca413171 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 13:06:23 -0500 Subject: [PATCH] Create protected hidden routes for admins --- __tests__/path.test.ts | 23 ++++++- package.json | 2 +- src/admin/AdminNavClient.tsx | 13 ++-- src/admin/AdminPhotosTable.tsx | 11 ++-- src/app/admin/photos/[photoId]/edit/page.tsx | 2 +- src/app/admin/photos/page.tsx | 2 +- src/app/tag/[tag]/page.tsx | 6 ++ src/app/tag/hidden/[photoId]/page.tsx | 58 ++++++++++++++++ src/app/tag/hidden/page.tsx | 69 ++++++++++++++++++++ src/components/Banner.tsx | 36 ++++++++++ src/components/FieldSetWithStatus.tsx | 2 +- src/photo/PhotoDetailPage.tsx | 11 +++- src/photo/PhotoSetHeader.tsx | 10 +-- src/photo/cache.ts | 7 ++ src/photo/db.ts | 50 +++++++++----- src/photo/form/index.ts | 11 ++-- src/photo/index.ts | 2 +- src/site/paths.ts | 20 +++--- src/tag/FavsTag.tsx | 19 +++--- src/tag/HiddenHeader.tsx | 23 +++++++ src/tag/HiddenTag.tsx | 34 ++++++++++ src/tag/TagHeader.tsx | 2 +- src/tag/index.ts | 15 +++-- 23 files changed, 357 insertions(+), 71 deletions(-) create mode 100644 src/app/tag/hidden/[photoId]/page.tsx create mode 100644 src/app/tag/hidden/page.tsx create mode 100644 src/components/Banner.tsx create mode 100644 src/tag/HiddenHeader.tsx create mode 100644 src/tag/HiddenTag.tsx diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index 858dfede..0552a40f 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -1,5 +1,4 @@ /* eslint-disable max-len */ -import '@testing-library/jest-dom'; import { getEscapePath, getPathComponents, @@ -13,11 +12,13 @@ import { isPathFilmSimulationShare, isPathPhoto, isPathPhotoShare, + isPathProtected, isPathTag, isPathTagPhoto, isPathTagPhotoShare, isPathTagShare, } from '@/site/paths'; +import { TAG_HIDDEN } from '@/tag'; const PHOTO_ID = 'UsKSGcbt'; const TAG = 'tag-name'; @@ -39,6 +40,11 @@ const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`; const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`; +const PATH_TAG_HIDDEN = `/tag/${TAG_HIDDEN}`; +const PATH_TAG_HIDDEN_SHARE = `${PATH_TAG_HIDDEN}/${SHARE}`; +const PATH_TAG_HIDDEN_PHOTO = `${PATH_TAG_HIDDEN}/${PHOTO_ID}`; +const PATH_TAG_HIDDEN_PHOTO_SHARE = `${PATH_TAG_HIDDEN_PHOTO}/${SHARE}`; + const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`; const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`; const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`; @@ -50,6 +56,21 @@ const PATH_FILM_SIMULATION_PHOTO = `${PATH_FILM_SIMULATION}/${PHOTO_ID}`; const PATH_FILM_SIMULATION_PHOTO_SHARE = `${PATH_FILM_SIMULATION_PHOTO}/${SHARE}`; describe('Paths', () => { + it('can be protected', () => { + // Public + expect(isPathProtected(PATH_ROOT)).toBe(false); + expect(isPathProtected(PATH_PHOTO)).toBe(false); + expect(isPathProtected(PATH_TAG)).toBe(false); + expect(isPathProtected(PATH_TAG_PHOTO)).toBe(false); + expect(isPathProtected(PATH_CAMERA)).toBe(false); + expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false); + // Private + expect(isPathProtected(PATH_ADMIN)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO_SHARE)).toBe(true); + }); it('can be classified', () => { // Positive expect(isPathPhoto(PATH_PHOTO)).toBe(true); diff --git a/package.json b/package.json index 343886e5..f1b0846d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "jest --watch", + "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "analyze": "ANALYZE=true next build" }, "dependencies": { diff --git a/src/admin/AdminNavClient.tsx b/src/admin/AdminNavClient.tsx index 6c30609b..b9bd1df8 100644 --- a/src/admin/AdminNavClient.tsx +++ b/src/admin/AdminNavClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import InfoBlock from '@/components/InfoBlock'; +import Banner from '@/components/Banner'; import SiteGrid from '@/components/SiteGrid'; import { PATH_ADMIN_CONFIGURATION, @@ -96,13 +96,10 @@ export default function AdminNavClient({ {shouldShowBanner && - -
- - Photo updates detected—they may take several minutes to show up - for visitors -
-
} + }> + Photo updates detected—they may take several minutes to show upe + for visitors + } } /> diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index f4effb3f..5073ddbe 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -50,15 +50,16 @@ export default function AdminPhotosTable({ prefetch={false} > - {titleForPhoto(photo)} - {photo.hidden && + {titleForPhoto(photo)} + {photo.hidden && + {' '} } + /> + } {photo.priorityOrder !== null && []), diff --git a/src/app/tag/[tag]/page.tsx b/src/app/tag/[tag]/page.tsx index 95a33ff4..ea436410 100644 --- a/src/app/tag/[tag]/page.tsx +++ b/src/app/tag/[tag]/page.tsx @@ -1,5 +1,6 @@ import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { PaginationParams } from '@/site/pagination'; +import { PATH_ROOT } from '@/site/paths'; import { generateMetaForTag } from '@/tag'; import TagOverview from '@/tag/TagOverview'; import { @@ -7,6 +8,7 @@ import { getPhotosTagDataCachedWithPagination, } from '@/tag/data'; import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; interface TagProps { params: { tag: string } @@ -25,6 +27,8 @@ export async function generateMetadata({ limit: GRID_THUMBNAILS_TO_SHOW_MAX, }); + if (photos.length === 0) { return {}; } + const { url, title, @@ -65,6 +69,8 @@ export default async function TagPage({ searchParams, }); + if (photos.length === 0) { redirect(PATH_ROOT); } + return ( ); diff --git a/src/app/tag/hidden/[photoId]/page.tsx b/src/app/tag/hidden/[photoId]/page.tsx new file mode 100644 index 00000000..0f091c14 --- /dev/null +++ b/src/app/tag/hidden/[photoId]/page.tsx @@ -0,0 +1,58 @@ +import { descriptionForPhoto, titleForPhoto } from '@/photo'; +import PhotoDetailPage from '@/photo/PhotoDetailPage'; +import { getPhotoCached, getPhotosCached } from '@/photo/cache'; +import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths'; +import { TAG_HIDDEN } from '@/tag'; +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { ReactNode, cache } from 'react'; + +const getPhotoCachedCached = cache(getPhotoCached); + +interface PhotoTagProps { + params: { photoId: string } +} + +export async function generateMetadata({ + params: { photoId }, +}: PhotoTagProps): Promise { + const photo = await getPhotoCachedCached(photoId, true); + + if (!photo) { return {}; } + + const title = titleForPhoto(photo); + const description = descriptionForPhoto(photo); + const url = absolutePathForPhoto(photo, TAG_HIDDEN); + + return { + title, + description, + openGraph: { + title, + description, + url, + }, + twitter: { + title, + description, + card: 'summary_large_image', + }, + }; +} + +export default async function PhotoTagPage({ + params: { photoId }, + children, +}: PhotoTagProps & { children: ReactNode }) { + const photo = await getPhotoCachedCached(photoId, true); + + if (!photo) { redirect(PATH_ROOT); } + + const photos = await getPhotosCached({ hidden: 'only' }); + const count = photos.length; + + return <> + {children} + + ; +} diff --git a/src/app/tag/hidden/page.tsx b/src/app/tag/hidden/page.tsx new file mode 100644 index 00000000..1c2b894a --- /dev/null +++ b/src/app/tag/hidden/page.tsx @@ -0,0 +1,69 @@ +import AnimateItems from '@/components/AnimateItems'; +import Banner from '@/components/Banner'; +import SiteGrid from '@/components/SiteGrid'; +import PhotoGrid from '@/photo/PhotoGrid'; +import { getPhotosCached, getPhotosTagHiddenMetaCached } from '@/photo/cache'; +import { absolutePathForTag } from '@/site/paths'; +import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag'; +import HiddenHeader from '@/tag/HiddenHeader'; +import { Metadata } from 'next'; +import { cache } from 'react'; + +const getPhotosTagHiddenMetaCachedCached = cache(getPhotosTagHiddenMetaCached); + +export async function generateMetadata(): Promise { + const { count, dateRange } = await getPhotosTagHiddenMetaCachedCached(); + + if (count === 0) { return {}; } + + const title = titleForTag(TAG_HIDDEN, undefined, count); + const description = descriptionForTaggedPhotos( + undefined, + undefined, + count, + dateRange, + ); + const url = absolutePathForTag(TAG_HIDDEN); + + return { + title, + openGraph: { + title, + description, + url, + }, + twitter: { + description, + card: 'summary_large_image', + }, + description, + }; +} + +export default async function HiddenTagPage() { + const [ + photos, + { count, dateRange }, + ] = await Promise.all([ + getPhotosCached({ hidden: 'only' }), + getPhotosTagHiddenMetaCached(), + ]); + return ( + + ]} + animateOnFirstLoadOnly + /> + + Only authenticated admins can see hidden photos. + + + } + /> + ); +} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx new file mode 100644 index 00000000..bab0b631 --- /dev/null +++ b/src/components/Banner.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react'; +import InfoBlock from './InfoBlock'; +import AnimateItems from './AnimateItems'; + +export default function Banner({ + icon, + children, + animate, + className, +}: { + icon?: ReactNode + children: ReactNode + animate?: boolean + className?: string +}) { + return ( + +
+ {icon} + {children} +
+ , + ]} + animateOnFirstLoadOnly + /> + ); +} diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index bf8de66d..2c0895b3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -59,7 +59,7 @@ export default function FieldSetWithStatus({ ({note}) } - {isModified && + {isModified && !error && diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index d0f0eb4b..23785659 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -10,6 +10,8 @@ import { Camera } from '@/camera'; import CameraHeader from '@/camera/CameraHeader'; import { FilmSimulation } from '@/simulation'; import FilmSimulationHeader from '@/simulation/FilmSimulationHeader'; +import { TAG_HIDDEN } from '@/tag'; +import HiddenHeader from '@/tag/HiddenHeader'; export default function PhotoDetailPage({ photo, @@ -35,8 +37,13 @@ export default function PhotoDetailPage({ {tag && + : {entity} @@ -59,9 +59,9 @@ export default function PhotoSetHeader({ )}> {selectedPhotoIndex !== undefined // eslint-disable-next-line max-len - ? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}` + ? `${entityVerb ? `${entityVerb} ` : ''}${selectedPhotoIndex + 1} of ${count ?? photos.length}` : entityDescription} - {selectedPhotoIndex === undefined && + {selectedPhotoIndex === undefined && sharePath && 'sqlDeletePhoto', ); -const sqlGetPhoto = (id: string) => - safelyQueryPhotos( - () => sql`SELECT * FROM photos WHERE id=${id} LIMIT 1`, - 'sqlGetPhoto', - ); +const sqlGetPhoto = (id: string, includeHidden?: boolean) => includeHidden + ? sql`SELECT * FROM photos WHERE id=${id} LIMIT 1` + // eslint-disable-next-line max-len + : sql`SELECT * FROM photos WHERE id=${id} AND hidden IS NOT TRUE LIMIT 1`; const sqlGetPhotosCount = async () => sql` SELECT COUNT(*) FROM photos @@ -212,6 +211,17 @@ const sqlGetPhotosTagMeta = async (tag: string) => sql` : undefined, })); +const sqlGetPhotosTagHiddenMeta = async () => sql` + SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end + FROM photos + WHERE hidden IS TRUE + `.then(({ rows }) => ({ + count: parseInt(rows[0].count, 10), + ...rows[0]?.start && rows[0]?.end + ? { dateRange: rows[0] as PhotoDateRange } + : undefined, + })); + const sqlGetPhotosCameraMeta = async (camera: Camera) => sql` SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos @@ -297,7 +307,7 @@ export type GetPhotosOptions = { simulation?: FilmSimulation takenBefore?: Date takenAfterInclusive?: Date - includeHidden?: boolean + hidden?: 'exclude' | 'include' | 'only' } const safelyQueryPhotos = async ( @@ -359,7 +369,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { simulation, takenBefore, takenAfterInclusive, - includeHidden, + hidden = 'exclude', } = options; let sql = ['SELECT * FROM photos']; @@ -368,8 +378,14 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { // WHERE let wheres = [] as string[]; - if (!includeHidden) { + + switch (hidden) { + case 'exclude': wheres.push('hidden IS NOT TRUE'); + break; + case 'only': + wheres.push('hidden IS TRUE'); + break; } if (takenBefore) { wheres.push(`taken_at > $${valueIndex++}`); @@ -428,6 +444,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { export const getPhotosNearId = async ( id: string, limit: number, + onlyHidden?: boolean, ) => { const orderBy = PRIORITY_ORDER_ENABLED ? 'ORDER BY priority_order ASC, taken_at DESC' @@ -440,7 +457,7 @@ export const getPhotosNearId = async ( SELECT *, row_number() OVER (${orderBy}) as row_number FROM photos - WHERE hidden IS NOT TRUE + WHERE hidden is ${onlyHidden ? 'TRUE' : 'NOT TRUE'} ), current AS (SELECT row_number FROM twi WHERE id = $1) SELECT twi.* @@ -468,11 +485,15 @@ export const getPhotoIds = async ({ limit }: { limit?: number }) => { .then(({ rows }) => rows.map(({ id }) => id as string)); }; -export const getPhoto = async (id: string): Promise => { +export const getPhoto = async ( + id: string, + includeHidden?: boolean, +): Promise => { // Check for photo id forwarding // and convert short ids to uuids const photoId = translatePhotoId(id); - return safelyQueryPhotos(() => sqlGetPhoto(photoId), 'getPhoto') + return safelyQueryPhotos(() => + sqlGetPhoto(photoId, includeHidden), 'sqlGetPhoto') .then(({ rows }) => rows.map(parsePhotoFromDb)) .then(photos => photos.length > 0 ? photos[0] : undefined); }; @@ -497,10 +518,9 @@ export const getUniqueTags = () => export const getUniqueTagsHidden = () => safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden'); export const getPhotosTagMeta = (tag: string) => - safelyQueryPhotos( - () => sqlGetPhotosTagMeta(tag), - 'getPhotosTagMeta', - ); + safelyQueryPhotos(() => sqlGetPhotosTagMeta(tag), 'getPhotosTagMeta'); +export const getPhotosTagHiddenMeta = () => + safelyQueryPhotos(sqlGetPhotosTagHiddenMeta, 'sqlGetPhotosTagHiddenMeta'); // CAMERAS export const getUniqueCameras = () => diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 9b9cf7ab..e6df9f75 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -16,7 +16,7 @@ import { } from '@/vendors/fujifilm'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/site/config'; -import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag'; +import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag'; type VirtualFields = 'favorite'; @@ -76,8 +76,8 @@ const FORM_METADATA = ( tags: { label: 'tags', tagOptions, - validate: tags => doesTagsStringIncludeFavs(tags) - ? `'${TAG_FAVS}' is a reserved tag` + validate: tags => doesStringContainReservedTags(tags) + ? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})` : undefined, }, semanticDescription: { @@ -141,10 +141,9 @@ export const isFormValid = (formData: Partial) => FORM_METADATA_ENTRIES().every( ([key, { required, validate, validateStringMaxLength }]) => (!required || Boolean(formData[key])) && - (validate?.(formData[key]) === undefined) && + (!validate?.(formData[key])) && // eslint-disable-next-line max-len - (!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) && - (key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? '')) + (!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) ); export const formHasTextContent = ({ diff --git a/src/photo/index.ts b/src/photo/index.ts index e31687ef..264876cf 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -201,7 +201,7 @@ export const deleteConfirmationTextForPhoto = (photo: Photo) => export type PhotoDateRange = { start: string, end: string }; export const descriptionForPhotoSet = ( - photos:Photo[], + photos:Photo[] = [], descriptor?: string, dateBased?: boolean, explicitCount?: number, diff --git a/src/site/paths.ts b/src/site/paths.ts index 6ef1962c..e18af551 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -3,6 +3,7 @@ import { BASE_URL } from './config'; import { Camera } from '@/camera'; import { FilmSimulation } from '@/simulation'; import { parameterize } from '@/utility/string'; +import { TAG_HIDDEN } from '@/tag'; // Core paths export const PATH_ROOT = '/'; @@ -98,13 +99,15 @@ export const pathForPhoto = ( camera?: Camera, simulation?: FilmSimulation, ) => - tag - ? `${pathForTag(tag)}/${getPhotoId(photo)}` - : camera - ? `${pathForCamera(camera)}/${getPhotoId(photo)}` - : simulation - ? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}` - : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; + typeof photo !== 'string' && photo.hidden + ? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}` + : tag + ? `${pathForTag(tag)}/${getPhotoId(photo)}` + : camera + ? `${pathForCamera(camera)}/${getPhotoId(photo)}` + : simulation + ? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}` + : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; export const pathForPhotoShare = ( photo: PhotoOrPhotoId, @@ -248,7 +251,8 @@ export const isPathAdminConfiguration = (pathname?: string) => checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION); export const isPathProtected = (pathname?: string) => - checkPathPrefix(pathname, PATH_ADMIN); + checkPathPrefix(pathname, PATH_ADMIN) || + checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)); export const getPathComponents = (pathname = ''): { photoId?: string diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index 7bc211da..f2e82c3d 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -17,16 +17,15 @@ export default function FavsTag({ } & EntityLinkExternalProps) { return ( - {TAG_FAVS} - - - : TAG_FAVS} + label={badged + ? + {TAG_FAVS} + + + : TAG_FAVS} href={pathForTag(TAG_FAVS)} icon={!badged && } + entityDescription={photoQuantityText(count, false)} + photos={photos} + selectedPhoto={selectedPhoto} + /> + ); +} diff --git a/src/tag/HiddenTag.tsx b/src/tag/HiddenTag.tsx new file mode 100644 index 00000000..ba8e673a --- /dev/null +++ b/src/tag/HiddenTag.tsx @@ -0,0 +1,34 @@ +import { TAG_HIDDEN } from '.'; +import { pathForTag } from '@/site/paths'; +import EntityLink, { + EntityLinkExternalProps, +} from '@/components/primitives/EntityLink'; +import { AiOutlineEyeInvisible } from 'react-icons/ai'; + +export default function HiddenTag({ + type, + badged, + contrast, + prefetch, + countOnHover, +}: { + countOnHover?: number +} & EntityLinkExternalProps) { + return ( + + {TAG_HIDDEN} + + + : TAG_HIDDEN} + href={pathForTag(TAG_HIDDEN)} + icon={!badged && } + type={type} + hoverEntity={countOnHover} + badged={badged} + contrast={contrast} + prefetch={prefetch} + /> + ); +} diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index fe97f856..eeb812c0 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -21,7 +21,7 @@ export default function TagHeader({ return ( + ? : } entityVerb="Tagged" entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} diff --git a/src/tag/index.ts b/src/tag/index.ts index ec42f22c..a1dc3bbd 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -11,7 +11,9 @@ import { } from '@/site/paths'; import { capitalizeWords, convertStringToArray } from '@/utility/string'; -export const TAG_FAVS = 'favs'; +// Reserved/virtual tags +export const TAG_FAVS = 'favs'; // Reserved +export const TAG_HIDDEN = 'hidden'; // Virtual export type TagsWithMeta = { tag: string @@ -21,12 +23,15 @@ export type TagsWithMeta = { export const formatTag = (tag?: string) => capitalizeWords(tag?.replaceAll('-', ' ')); -export const doesTagsStringIncludeFavs = (tags?: string) => - convertStringToArray(tags)?.some(tag => isTagFavs(tag)); +export const doesStringContainReservedTags = (tags?: string) => + convertStringToArray(tags)?.some(tag => ( + isTagFavs(tag) || + tag.toLowerCase() === TAG_HIDDEN + )); export const titleForTag = ( tag: string, - photos:Photo[], + photos:Photo[] = [], explicitCount?: number, ) => [ formatTag(tag), @@ -54,7 +59,7 @@ export const sortTagsObjectWithoutFavs = (tags: TagsWithMeta) => sortTagsObject(tags, TAG_FAVS); export const descriptionForTaggedPhotos = ( - photos: Photo[], + photos: Photo[] = [], dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange,