Create protected hidden routes for admins

This commit is contained in:
Sam Becker 2024-05-12 13:06:23 -05:00
parent fe7a016491
commit c0f4f1fbf1
23 changed files with 357 additions and 71 deletions

View File

@ -1,5 +1,4 @@
/* eslint-disable max-len */ /* eslint-disable max-len */
import '@testing-library/jest-dom';
import { import {
getEscapePath, getEscapePath,
getPathComponents, getPathComponents,
@ -13,11 +12,13 @@ import {
isPathFilmSimulationShare, isPathFilmSimulationShare,
isPathPhoto, isPathPhoto,
isPathPhotoShare, isPathPhotoShare,
isPathProtected,
isPathTag, isPathTag,
isPathTagPhoto, isPathTagPhoto,
isPathTagPhotoShare, isPathTagPhotoShare,
isPathTagShare, isPathTagShare,
} from '@/site/paths'; } from '@/site/paths';
import { TAG_HIDDEN } from '@/tag';
const PHOTO_ID = 'UsKSGcbt'; const PHOTO_ID = 'UsKSGcbt';
const TAG = 'tag-name'; 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 = `${PATH_TAG}/${PHOTO_ID}`;
const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`; 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 = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`;
const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`; const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`;
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`; 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}`; const PATH_FILM_SIMULATION_PHOTO_SHARE = `${PATH_FILM_SIMULATION_PHOTO}/${SHARE}`;
describe('Paths', () => { 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', () => { it('can be classified', () => {
// Positive // Positive
expect(isPathPhoto(PATH_PHOTO)).toBe(true); expect(isPathPhoto(PATH_PHOTO)).toBe(true);

View File

@ -5,7 +5,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "jest --watch", "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import InfoBlock from '@/components/InfoBlock'; import Banner from '@/components/Banner';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import { import {
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
@ -96,13 +96,10 @@ export default function AdminNavClient({
</Link> </Link>
</div> </div>
{shouldShowBanner && {shouldShowBanner &&
<InfoBlock centered={false} padding="tight" color="blue"> <Banner icon={<FaRegClock className="flex-shrink-0" />}>
<div className="flex items-center gap-3"> Photo updates detectedthey may take several minutes to show upe
<FaRegClock className="flex-shrink-0" /> for visitors
Photo updates detectedthey may take several minutes to show up </Banner>}
for visitors
</div>
</InfoBlock>}
</div> </div>
} }
/> />

View File

@ -50,15 +50,16 @@ export default function AdminPhotosTable({
prefetch={false} prefetch={false}
> >
<span className={clsx( <span className={clsx(
'inline-flex items-center gap-2',
photo.hidden && 'text-dim', photo.hidden && 'text-dim',
)}> )}>
<span>{titleForPhoto(photo)}</span> {titleForPhoto(photo)}
{photo.hidden && {photo.hidden && <span className="whitespace-nowrap">
{' '}
<AiOutlineEyeInvisible <AiOutlineEyeInvisible
className="translate-y-[0.25px]" className="inline translate-y-[-0.5px]"
size={16} size={16}
/>} />
</span>}
</span> </span>
{photo.priorityOrder !== null && {photo.priorityOrder !== null &&
<span className={clsx( <span className={clsx(

View File

@ -11,7 +11,7 @@ export default async function PhotoEditPage({
}: { }: {
params: { photoId: string } params: { photoId: string }
}) { }) {
const photo = await getPhotoNoStore(photoId); const photo = await getPhotoNoStore(photoId, true);
if (!photo) { redirect(PATH_ADMIN); } if (!photo) { redirect(PATH_ADMIN); }

View File

@ -23,7 +23,7 @@ export default async function AdminPhotosPage() {
blobPhotoUrls, blobPhotoUrls,
] = await Promise.all([ ] = await Promise.all([
getPhotos({ getPhotos({
includeHidden: true, hidden: 'include',
sortBy: 'createdAt', sortBy: 'createdAt',
limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS, limit: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS,
}).catch(() => []), }).catch(() => []),

View File

@ -1,5 +1,6 @@
import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo';
import { PaginationParams } from '@/site/pagination'; import { PaginationParams } from '@/site/pagination';
import { PATH_ROOT } from '@/site/paths';
import { generateMetaForTag } from '@/tag'; import { generateMetaForTag } from '@/tag';
import TagOverview from '@/tag/TagOverview'; import TagOverview from '@/tag/TagOverview';
import { import {
@ -7,6 +8,7 @@ import {
getPhotosTagDataCachedWithPagination, getPhotosTagDataCachedWithPagination,
} from '@/tag/data'; } from '@/tag/data';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
interface TagProps { interface TagProps {
params: { tag: string } params: { tag: string }
@ -25,6 +27,8 @@ export async function generateMetadata({
limit: GRID_THUMBNAILS_TO_SHOW_MAX, limit: GRID_THUMBNAILS_TO_SHOW_MAX,
}); });
if (photos.length === 0) { return {}; }
const { const {
url, url,
title, title,
@ -65,6 +69,8 @@ export default async function TagPage({
searchParams, searchParams,
}); });
if (photos.length === 0) { redirect(PATH_ROOT); }
return ( return (
<TagOverview {...{ tag, photos, count, dateRange, showMorePath }} /> <TagOverview {...{ tag, photos, count, dateRange, showMorePath }} />
); );

View File

@ -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<Metadata> {
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}
<PhotoDetailPage {...{ photo, photos, count, tag: TAG_HIDDEN }} />
</>;
}

View File

@ -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<Metadata> {
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 (
<SiteGrid
contentMain={<div className="space-y-8 mt-4">
<AnimateItems
type="bottom"
items={[<HiddenHeader
key="HiddenHeader"
{...{ photos, count, dateRange }}
/>]}
animateOnFirstLoadOnly
/>
<Banner animate>
Only authenticated admins can see hidden photos.
</Banner>
<PhotoGrid {...{ photos }} />
</div>}
/>
);
}

36
src/components/Banner.tsx Normal file
View File

@ -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 (
<AnimateItems
type={animate ? 'bottom' : 'none'}
items={[
<InfoBlock
key="Banner"
className={className}
centered={false}
padding="tight"
color="blue"
>
<div className="flex items-center gap-3">
{icon}
{children}
</div>
</InfoBlock>,
]}
animateOnFirstLoadOnly
/>
);
}

View File

@ -59,7 +59,7 @@ export default function FieldSetWithStatus({
<span className="text-gray-400 dark:text-gray-600"> <span className="text-gray-400 dark:text-gray-600">
({note}) ({note})
</span>} </span>}
{isModified && {isModified && !error &&
<span className={clsx( <span className={clsx(
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]' 'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
)}> )}>

View File

@ -10,6 +10,8 @@ import { Camera } from '@/camera';
import CameraHeader from '@/camera/CameraHeader'; import CameraHeader from '@/camera/CameraHeader';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import FilmSimulationHeader from '@/simulation/FilmSimulationHeader'; import FilmSimulationHeader from '@/simulation/FilmSimulationHeader';
import { TAG_HIDDEN } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader';
export default function PhotoDetailPage({ export default function PhotoDetailPage({
photo, photo,
@ -35,8 +37,13 @@ export default function PhotoDetailPage({
{tag && {tag &&
<SiteGrid <SiteGrid
className="mt-4 mb-8" className="mt-4 mb-8"
contentMain={ contentMain={tag === TAG_HIDDEN
<TagHeader ? <HiddenHeader
photos={photos}
selectedPhoto={photo}
count={count ?? 0}
/>
: <TagHeader
key={tag} key={tag}
tag={tag} tag={tag}
photos={photos} photos={photos}

View File

@ -17,11 +17,11 @@ export default function PhotoSetHeader({
dateRange, dateRange,
}: { }: {
entity: ReactNode entity: ReactNode
entityVerb: string entityVerb?: string
entityDescription: string entityDescription: string
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
sharePath: string sharePath?: string
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
@ -45,7 +45,7 @@ export default function PhotoSetHeader({
: 'xs:grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4', : 'xs:grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
)}> )}>
<span className={clsx( <span className={clsx(
'inline-flex', 'inline-flex uppercase',
HIGH_DENSITY_GRID && 'sm:col-span-2', HIGH_DENSITY_GRID && 'sm:col-span-2',
)}> )}>
{entity} {entity}
@ -59,9 +59,9 @@ export default function PhotoSetHeader({
)}> )}>
{selectedPhotoIndex !== undefined {selectedPhotoIndex !== undefined
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}` ? `${entityVerb ? `${entityVerb} ` : ''}${selectedPhotoIndex + 1} of ${count ?? photos.length}`
: entityDescription} : entityDescription}
{selectedPhotoIndex === undefined && {selectedPhotoIndex === undefined && sharePath &&
<ShareButton <ShareButton
className="translate-y-[1.5px]" className="translate-y-[1.5px]"
path={sharePath} path={sharePath}

View File

@ -20,6 +20,7 @@ import {
getPhotosDateRange, getPhotosDateRange,
getPhotosNearId, getPhotosNearId,
getPhotosMostRecentUpdate, getPhotosMostRecentUpdate,
getPhotosTagHiddenMeta,
} from '@/photo/db'; } from '@/photo/db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera'; import { createCameraKey } from '@/camera';
@ -179,6 +180,12 @@ export const getPhotosTagMetaCached =
[KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE], [KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE],
); );
export const getPhotosTagHiddenMetaCached =
unstable_cache(
getPhotosTagHiddenMeta,
[KEY_PHOTOS, KEY_TAGS, KEY_HIDDEN, KEY_DATE_RANGE],
);
export const getPhotosCameraMetaCached = export const getPhotosCameraMetaCached =
unstable_cache( unstable_cache(
getPhotosCameraMeta, getPhotosCameraMeta,

View File

@ -173,11 +173,10 @@ export const sqlDeletePhoto = (id: string) =>
'sqlDeletePhoto', 'sqlDeletePhoto',
); );
const sqlGetPhoto = (id: string) => const sqlGetPhoto = (id: string, includeHidden?: boolean) => includeHidden
safelyQueryPhotos( ? sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`
() => sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} LIMIT 1`, // eslint-disable-next-line max-len
'sqlGetPhoto', : sql<PhotoDb>`SELECT * FROM photos WHERE id=${id} AND hidden IS NOT TRUE LIMIT 1`;
);
const sqlGetPhotosCount = async () => sql` const sqlGetPhotosCount = async () => sql`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
@ -212,6 +211,17 @@ const sqlGetPhotosTagMeta = async (tag: string) => sql`
: undefined, : 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` const sqlGetPhotosCameraMeta = async (camera: Camera) => sql`
SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos FROM photos
@ -297,7 +307,7 @@ export type GetPhotosOptions = {
simulation?: FilmSimulation simulation?: FilmSimulation
takenBefore?: Date takenBefore?: Date
takenAfterInclusive?: Date takenAfterInclusive?: Date
includeHidden?: boolean hidden?: 'exclude' | 'include' | 'only'
} }
const safelyQueryPhotos = async <T>( const safelyQueryPhotos = async <T>(
@ -359,7 +369,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
simulation, simulation,
takenBefore, takenBefore,
takenAfterInclusive, takenAfterInclusive,
includeHidden, hidden = 'exclude',
} = options; } = options;
let sql = ['SELECT * FROM photos']; let sql = ['SELECT * FROM photos'];
@ -368,8 +378,14 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
// WHERE // WHERE
let wheres = [] as string[]; let wheres = [] as string[];
if (!includeHidden) {
switch (hidden) {
case 'exclude':
wheres.push('hidden IS NOT TRUE'); wheres.push('hidden IS NOT TRUE');
break;
case 'only':
wheres.push('hidden IS TRUE');
break;
} }
if (takenBefore) { if (takenBefore) {
wheres.push(`taken_at > $${valueIndex++}`); wheres.push(`taken_at > $${valueIndex++}`);
@ -428,6 +444,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
export const getPhotosNearId = async ( export const getPhotosNearId = async (
id: string, id: string,
limit: number, limit: number,
onlyHidden?: boolean,
) => { ) => {
const orderBy = PRIORITY_ORDER_ENABLED const orderBy = PRIORITY_ORDER_ENABLED
? 'ORDER BY priority_order ASC, taken_at DESC' ? 'ORDER BY priority_order ASC, taken_at DESC'
@ -440,7 +457,7 @@ export const getPhotosNearId = async (
SELECT *, row_number() SELECT *, row_number()
OVER (${orderBy}) as row_number OVER (${orderBy}) as row_number
FROM photos FROM photos
WHERE hidden IS NOT TRUE WHERE hidden is ${onlyHidden ? 'TRUE' : 'NOT TRUE'}
), ),
current AS (SELECT row_number FROM twi WHERE id = $1) current AS (SELECT row_number FROM twi WHERE id = $1)
SELECT twi.* SELECT twi.*
@ -468,11 +485,15 @@ export const getPhotoIds = async ({ limit }: { limit?: number }) => {
.then(({ rows }) => rows.map(({ id }) => id as string)); .then(({ rows }) => rows.map(({ id }) => id as string));
}; };
export const getPhoto = async (id: string): Promise<Photo | undefined> => { export const getPhoto = async (
id: string,
includeHidden?: boolean,
): Promise<Photo | undefined> => {
// Check for photo id forwarding // Check for photo id forwarding
// and convert short ids to uuids // and convert short ids to uuids
const photoId = translatePhotoId(id); const photoId = translatePhotoId(id);
return safelyQueryPhotos(() => sqlGetPhoto(photoId), 'getPhoto') return safelyQueryPhotos(() =>
sqlGetPhoto(photoId, includeHidden), 'sqlGetPhoto')
.then(({ rows }) => rows.map(parsePhotoFromDb)) .then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined); .then(photos => photos.length > 0 ? photos[0] : undefined);
}; };
@ -497,10 +518,9 @@ export const getUniqueTags = () =>
export const getUniqueTagsHidden = () => export const getUniqueTagsHidden = () =>
safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden'); safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden');
export const getPhotosTagMeta = (tag: string) => export const getPhotosTagMeta = (tag: string) =>
safelyQueryPhotos( safelyQueryPhotos(() => sqlGetPhotosTagMeta(tag), 'getPhotosTagMeta');
() => sqlGetPhotosTagMeta(tag), export const getPhotosTagHiddenMeta = () =>
'getPhotosTagMeta', safelyQueryPhotos(sqlGetPhotosTagHiddenMeta, 'sqlGetPhotosTagHiddenMeta');
);
// CAMERAS // CAMERAS
export const getUniqueCameras = () => export const getUniqueCameras = () =>

View File

@ -16,7 +16,7 @@ import {
} from '@/vendors/fujifilm'; } from '@/vendors/fujifilm';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { GEO_PRIVACY_ENABLED } from '@/site/config'; import { GEO_PRIVACY_ENABLED } from '@/site/config';
import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag'; import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag';
type VirtualFields = 'favorite'; type VirtualFields = 'favorite';
@ -76,8 +76,8 @@ const FORM_METADATA = (
tags: { tags: {
label: 'tags', label: 'tags',
tagOptions, tagOptions,
validate: tags => doesTagsStringIncludeFavs(tags) validate: tags => doesStringContainReservedTags(tags)
? `'${TAG_FAVS}' is a reserved tag` ? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})`
: undefined, : undefined,
}, },
semanticDescription: { semanticDescription: {
@ -141,10 +141,9 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
FORM_METADATA_ENTRIES().every( FORM_METADATA_ENTRIES().every(
([key, { required, validate, validateStringMaxLength }]) => ([key, { required, validate, validateStringMaxLength }]) =>
(!required || Boolean(formData[key])) && (!required || Boolean(formData[key])) &&
(validate?.(formData[key]) === undefined) && (!validate?.(formData[key])) &&
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) && (!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength)
(key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? ''))
); );
export const formHasTextContent = ({ export const formHasTextContent = ({

View File

@ -201,7 +201,7 @@ export const deleteConfirmationTextForPhoto = (photo: Photo) =>
export type PhotoDateRange = { start: string, end: string }; export type PhotoDateRange = { start: string, end: string };
export const descriptionForPhotoSet = ( export const descriptionForPhotoSet = (
photos:Photo[], photos:Photo[] = [],
descriptor?: string, descriptor?: string,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,

View File

@ -3,6 +3,7 @@ import { BASE_URL } from './config';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { TAG_HIDDEN } from '@/tag';
// Core paths // Core paths
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
@ -98,13 +99,15 @@ export const pathForPhoto = (
camera?: Camera, camera?: Camera,
simulation?: FilmSimulation, simulation?: FilmSimulation,
) => ) =>
tag typeof photo !== 'string' && photo.hidden
? `${pathForTag(tag)}/${getPhotoId(photo)}` ? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
: camera : tag
? `${pathForCamera(camera)}/${getPhotoId(photo)}` ? `${pathForTag(tag)}/${getPhotoId(photo)}`
: simulation : camera
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}` ? `${pathForCamera(camera)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`; : simulation
? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
export const pathForPhotoShare = ( export const pathForPhotoShare = (
photo: PhotoOrPhotoId, photo: PhotoOrPhotoId,
@ -248,7 +251,8 @@ export const isPathAdminConfiguration = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION); checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION);
export const isPathProtected = (pathname?: string) => export const isPathProtected = (pathname?: string) =>
checkPathPrefix(pathname, PATH_ADMIN); checkPathPrefix(pathname, PATH_ADMIN) ||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN));
export const getPathComponents = (pathname = ''): { export const getPathComponents = (pathname = ''): {
photoId?: string photoId?: string

View File

@ -17,16 +17,15 @@ export default function FavsTag({
} & EntityLinkExternalProps) { } & EntityLinkExternalProps) {
return ( return (
<EntityLink <EntityLink
label={ label={badged
badged ? <span className="inline-flex gap-1">
? <span className="inline-flex gap-1"> {TAG_FAVS}
{TAG_FAVS} <FaStar
<FaStar size={10}
size={10} className="text-amber-500"
className="text-amber-500" />
/> </span>
</span> : TAG_FAVS}
: TAG_FAVS}
href={pathForTag(TAG_FAVS)} href={pathForTag(TAG_FAVS)}
icon={!badged && icon={!badged &&
<FaStar <FaStar

23
src/tag/HiddenHeader.tsx Normal file
View File

@ -0,0 +1,23 @@
import { Photo, photoQuantityText } from '@/photo';
import PhotoSetHeader from '@/photo/PhotoSetHeader';
import HiddenTag from './HiddenTag';
export default function HiddenHeader({
photos,
selectedPhoto,
count,
}: {
photos: Photo[]
selectedPhoto?: Photo
count: number
}) {
return (
<PhotoSetHeader
key="HiddenHeader"
entity={<HiddenTag contrast="high" />}
entityDescription={photoQuantityText(count, false)}
photos={photos}
selectedPhoto={selectedPhoto}
/>
);
}

34
src/tag/HiddenTag.tsx Normal file
View File

@ -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 (
<EntityLink
label={badged
? <span className="inline-flex gap-1">
{TAG_HIDDEN}
<AiOutlineEyeInvisible size={14} />
</span>
: TAG_HIDDEN}
href={pathForTag(TAG_HIDDEN)}
icon={!badged && <AiOutlineEyeInvisible size={16} />}
type={type}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}
prefetch={prefetch}
/>
);
}

View File

@ -21,7 +21,7 @@ export default function TagHeader({
return ( return (
<PhotoSetHeader <PhotoSetHeader
entity={isTagFavs(tag) entity={isTagFavs(tag)
? <FavsTag /> ? <FavsTag contrast="high" />
: <PhotoTag tag={tag} contrast="high" />} : <PhotoTag tag={tag} contrast="high" />}
entityVerb="Tagged" entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}

View File

@ -11,7 +11,9 @@ import {
} from '@/site/paths'; } from '@/site/paths';
import { capitalizeWords, convertStringToArray } from '@/utility/string'; 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 = { export type TagsWithMeta = {
tag: string tag: string
@ -21,12 +23,15 @@ export type TagsWithMeta = {
export const formatTag = (tag?: string) => export const formatTag = (tag?: string) =>
capitalizeWords(tag?.replaceAll('-', ' ')); capitalizeWords(tag?.replaceAll('-', ' '));
export const doesTagsStringIncludeFavs = (tags?: string) => export const doesStringContainReservedTags = (tags?: string) =>
convertStringToArray(tags)?.some(tag => isTagFavs(tag)); convertStringToArray(tags)?.some(tag => (
isTagFavs(tag) ||
tag.toLowerCase() === TAG_HIDDEN
));
export const titleForTag = ( export const titleForTag = (
tag: string, tag: string,
photos:Photo[], photos:Photo[] = [],
explicitCount?: number, explicitCount?: number,
) => [ ) => [
formatTag(tag), formatTag(tag),
@ -54,7 +59,7 @@ export const sortTagsObjectWithoutFavs = (tags: TagsWithMeta) =>
sortTagsObject(tags, TAG_FAVS); sortTagsObject(tags, TAG_FAVS);
export const descriptionForTaggedPhotos = ( export const descriptionForTaggedPhotos = (
photos: Photo[], photos: Photo[] = [],
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,