From c0f4f1fbf1df7f170c78bcb36cda0938ca413171 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 13:06:23 -0500 Subject: [PATCH 1/8] 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, From ac3b71142e1b2bd947d0991a3565e4dc6b1c5c8e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 13:13:48 -0500 Subject: [PATCH 2/8] Fix hidden photo page type --- src/app/tag/hidden/[photoId]/page.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/tag/hidden/[photoId]/page.tsx b/src/app/tag/hidden/[photoId]/page.tsx index 0f091c14..5f7e4e91 100644 --- a/src/app/tag/hidden/[photoId]/page.tsx +++ b/src/app/tag/hidden/[photoId]/page.tsx @@ -5,7 +5,7 @@ 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'; +import { cache } from 'react'; const getPhotoCachedCached = cache(getPhotoCached); @@ -40,10 +40,9 @@ export async function generateMetadata({ }; } -export default async function PhotoTagPage({ +export default async function PhotoTagHiddenPage({ params: { photoId }, - children, -}: PhotoTagProps & { children: ReactNode }) { +}: PhotoTagProps) { const photo = await getPhotoCachedCached(photoId, true); if (!photo) { redirect(PATH_ROOT); } @@ -51,8 +50,7 @@ export default async function PhotoTagPage({ const photos = await getPhotosCached({ hidden: 'only' }); const count = photos.length; - return <> - {children} + return ( - ; + ); } From 33469a60ee98eabeea726191f3a78ebbf0c54837 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 15:27:15 -0500 Subject: [PATCH 3/8] Update getPhoto action signatures --- src/photo/InfinitePhotoScroll.tsx | 4 ++-- src/photo/actions.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 64cf4df8..4c221e18 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -58,12 +58,12 @@ export default function InfinitePhotoScroll({ ? getPhotosCachedAction( initialOffset + size * itemsPerPage, itemsPerPage, - includeHiddenPhotos, + includeHiddenPhotos ? 'include' : 'exclude', ) : getPhotosAction( initialOffset + size * itemsPerPage, itemsPerPage, - includeHiddenPhotos, + includeHiddenPhotos ? 'include' : 'exclude', ) , [useCachedPhotos, initialOffset, itemsPerPage, includeHiddenPhotos]); diff --git a/src/photo/actions.ts b/src/photo/actions.ts index f2d190dc..e1e09d11 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { + GetPhotosOptions, sqlDeletePhoto, sqlInsertPhoto, sqlDeletePhotoTagGlobally, @@ -201,16 +202,16 @@ export const getImageBlurAction = async (url: string) => export const getPhotosAction = async ( offset: number, limit: number, - includeHidden?: boolean, + hidden?: GetPhotosOptions['hidden'], ) => - getPhotos({ offset, includeHidden, limit }); + getPhotos({ offset, hidden, limit }); export const getPhotosCachedAction = async ( offset: number, limit: number, - includeHidden?: boolean, + hidden?: GetPhotosOptions['hidden'], ) => - getPhotosCachedCached({ offset, includeHidden, limit }); + getPhotosCachedCached({ offset, hidden, limit }); export const queryPhotosByTitleAction = async (query: string) => (await getPhotos({ query, limit: 10 })) From 9c9541977f3d144db9ea2abab05003e59d204518 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 18:20:12 -0500 Subject: [PATCH 4/8] Add hidden to sidebar and cmd-k menu --- src/app/tag/hidden/page.tsx | 12 +++--- src/components/CommandKClient.tsx | 28 +++++++++++++- src/photo/PhotoGridSidebar.tsx | 63 +++++++++++++++++++++---------- src/photo/actions.ts | 20 +++++++--- src/site/CommandK.tsx | 22 ++--------- src/state/AppState.ts | 22 ++++++----- src/state/AppStateProvider.tsx | 50 ++++++++++++++++-------- src/tag/HiddenTag.tsx | 5 ++- src/tag/index.ts | 11 ++++++ 9 files changed, 158 insertions(+), 75 deletions(-) diff --git a/src/app/tag/hidden/page.tsx b/src/app/tag/hidden/page.tsx index 1c2b894a..796ff9e2 100644 --- a/src/app/tag/hidden/page.tsx +++ b/src/app/tag/hidden/page.tsx @@ -50,7 +50,7 @@ export default async function HiddenTagPage() { ]); return ( + contentMain={
]} animateOnFirstLoadOnly /> - - Only authenticated admins can see hidden photos. - - +
+ + Only visible to authenticated admins + + +
} /> ); diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx index c57696d5..c2d570e2 100644 --- a/src/components/CommandKClient.tsx +++ b/src/components/CommandKClient.tsx @@ -17,6 +17,7 @@ import { PATH_ADMIN_UPLOADS, PATH_SIGN_IN, pathForPhoto, + pathForTag, } from '../site/paths'; import Modal from './Modal'; import { clsx } from 'clsx/lite'; @@ -37,6 +38,9 @@ import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoTiny from '@/photo/PhotoTiny'; import { FaCheck } from 'react-icons/fa6'; +import { TagsWithMeta, addHiddenToTags } from '@/tag'; +import { FaTag } from 'react-icons/fa'; +import { formatCount, formatCountDescriptive } from '@/utility/string'; const LISTENER_KEYDOWN = 'keydown'; const MINIMUM_QUERY_LENGTH = 2; @@ -44,9 +48,9 @@ const MINIMUM_QUERY_LENGTH = 2; type CommandKItem = { label: string keywords?: string[] + accessory?: ReactNode annotation?: ReactNode annotationAria?: string - accessory?: ReactNode path?: string action?: () => void | Promise } @@ -58,10 +62,12 @@ export type CommandKSection = { } export default function CommandKClient({ + tags, serverSections = [], showDebugTools, footer, }: { + tags: TagsWithMeta serverSections?: CommandKSection[] showDebugTools?: boolean footer?: string @@ -70,6 +76,7 @@ export default function CommandKClient({ isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, + hiddenPhotosCount, arePhotosMatted, shouldShowBaselineGrid, shouldDebugBlur, @@ -173,6 +180,24 @@ export default function CommandKClient({ } }, [isOpen, setShouldRespondToKeyboardCommands]); + const tagsIncludingHidden = useMemo(() => + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + + const SECTION_TAGS: CommandKSection = { + heading: 'Tags', + accessory: , + items: tagsIncludingHidden.map(({ tag, count }) => ({ + label: tag, + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForTag(tag), + })), + }; + const clientSections: CommandKSection[] = [{ heading: 'Theme', accessory: {queriedSections + .concat(SECTION_TAGS) .concat(serverSections) .concat(sectionPages) .concat(adminSection) diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 09b1a6c0..0199ff63 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Cameras, sortCamerasWithCount } from '@/camera'; import PhotoCamera from '@/camera/PhotoCamera'; import HeaderList from '@/components/HeaderList'; @@ -5,11 +7,14 @@ import PhotoTag from '@/tag/PhotoTag'; import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.'; -import { TAG_FAVS, TagsWithMeta } from '@/tag'; +import { TAG_FAVS, TAG_HIDDEN, TagsWithMeta, addHiddenToTags } from '@/tag'; import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; import { FilmSimulations, sortFilmSimulationsWithCount } from '@/simulation'; import FavsTag from '../tag/FavsTag'; +import { useAppState } from '@/state/AppState'; +import { useMemo } from 'react'; +import HiddenTag from '@/tag/HiddenTag'; export default function PhotoGridSidebar({ tags, @@ -26,29 +31,49 @@ export default function PhotoGridSidebar({ }) { const { start, end } = dateRangeForPhotos(undefined, photosDateRange); + const { hiddenPhotosCount } = useAppState(); + + const tagsIncludingHidden = useMemo(() => + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + return ( <> {tags.length > 0 && } - items={tags.map(({ tag, count }) => tag === TAG_FAVS - ? - : )} + items={tagsIncludingHidden.map(({ tag, count }) => { + switch (tag) { + case TAG_FAVS: + return ; + case TAG_HIDDEN: + return ; + default: + return ; + } + })} />} {cameras.length > 0 && safelyRunAdminServerAction(() => blurImageFromUrl(url)); -// Public actions +export const getPhotosTagHiddenMetaCachedAction = async () => + safelyRunAdminServerAction(getPhotosTagHiddenMetaCached); + +// Public/Private actions export const getPhotosAction = async ( offset: number, limit: number, hidden?: GetPhotosOptions['hidden'], -) => - getPhotos({ offset, hidden, limit }); +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotos({ offset, hidden, limit })) + : getPhotos({ offset, hidden, limit }); export const getPhotosCachedAction = async ( offset: number, limit: number, hidden?: GetPhotosOptions['hidden'], -) => - getPhotosCachedCached({ offset, hidden, limit }); +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotosCachedCached({ offset, hidden, limit })) + : getPhotosCachedCached({ offset, hidden, limit }); + +// Public actions export const queryPhotosByTitleAction = async (query: string) => (await getPhotos({ query, limit: 10 })) diff --git a/src/site/CommandK.tsx b/src/site/CommandK.tsx index 77c7f39e..e62f87c8 100644 --- a/src/site/CommandK.tsx +++ b/src/site/CommandK.tsx @@ -8,14 +8,12 @@ import { import { pathForCamera, pathForFilmSimulation, - pathForTag, } from './paths'; import { formatCameraText } from '@/camera'; import { photoQuantityText } from '@/photo'; import { formatCount, formatCountDescriptive } from '@/utility/string'; -import { sortTagsObject } from '@/tag'; +import { TagsWithMeta } from '@/tag'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; -import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { ADMIN_DEBUG_TOOLS_ENABLED } from './config'; @@ -27,25 +25,11 @@ export default async function CommandK() { filmSimulations, ] = await Promise.all([ getPhotosCountCached().catch(() => 0), - getUniqueTagsCached().catch(() => []), + getUniqueTagsCached().catch(() => [] as TagsWithMeta), getUniqueCamerasCached().catch(() => []), getUniqueFilmSimulationsCached().catch(() => []), ]); - const SECTION_TAGS: CommandKSection = { - heading: 'Tags', - accessory: , - items: sortTagsObject(tags).map(({ tag, count }) => ({ - label: tag, - annotation: formatCount(count), - annotationAria: formatCountDescriptive(count), - path: pathForTag(tag), - })), - }; - const SECTION_CAMERAS: CommandKSection = { heading: 'Cameras', accessory: , @@ -71,8 +55,8 @@ export default async function CommandK() { }; return > + setHasLoaded?: Dispatch> swrTimestamp?: number invalidateSwr?: () => void - userEmail?: string - setUserEmail?: Dispatch> - isUserSignedIn?: boolean - setHasLoaded?: Dispatch> nextPhotoAnimation?: AnimationConfig setNextPhotoAnimation?: Dispatch> + clearNextPhotoAnimation?: () => void shouldRespondToKeyboardCommands?: boolean setShouldRespondToKeyboardCommands?: Dispatch> isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> + // ADMIN + userEmail?: string + setUserEmail?: Dispatch> + isUserSignedIn?: boolean adminUpdateTimes?: Date[] registerAdminUpdate?: () => void - shouldShowBaselineGrid?: boolean - setShouldShowBaselineGrid?: Dispatch> + hiddenPhotosCount?: number + // DEBUG + arePhotosMatted?: boolean + setArePhotosMatted?: Dispatch> shouldDebugBlur?: boolean setShouldDebugBlur?: Dispatch> - clearNextPhotoAnimation?: () => void + shouldShowBaselineGrid?: boolean + setShouldShowBaselineGrid?: Dispatch> } export const AppStateContext = createContext({}); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index d9967814..524053be 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -7,6 +7,7 @@ import usePathnames from '@/utility/usePathnames'; import { getAuthAction, logClientAuthUpdate } from '@/auth/actions'; import useSWR from 'swr'; import { MATTE_PHOTOS } from '@/site/config'; +import { getPhotosTagHiddenMetaCachedAction } from '@/photo/actions'; export default function AppStateProvider({ children, @@ -15,25 +16,31 @@ export default function AppStateProvider({ }) { const { previousPathname } = usePathnames(); + // CORE const [hasLoaded, setHasLoaded] = useState(false); - const [arePhotosMatted, setArePhotosMatted] = - useState(MATTE_PHOTOS); const [swrTimestamp, setSwrTimestamp] = useState(Date.now()); - const [userEmail, setUserEmail] = - useState(); const [nextPhotoAnimation, setNextPhotoAnimation] = useState(); const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] = useState(true); const [isCommandKOpen, setIsCommandKOpen] = useState(false); - const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); - const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = - useState(false); + // ADMIN + const [userEmail, setUserEmail] = + useState(); + const [adminUpdateTimes, setAdminUpdateTimes] = + useState([]); + const [hiddenPhotosCount, setHiddenPhotosCount] = + useState(0); + // DEBUG + const [arePhotosMatted, setArePhotosMatted] = + useState(MATTE_PHOTOS); const [shouldDebugBlur, setShouldDebugBlur] = useState(false); + const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = + useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -42,6 +49,13 @@ export default function AppStateProvider({ setUserEmail(data?.user?.email ?? undefined); logClientAuthUpdate(data); }, [data]); + const isUserSignedIn = userEmail !== undefined; + useEffect(() => { + if (isUserSignedIn) { + getPhotosTagHiddenMetaCachedAction().then(({ count }) => + setHiddenPhotosCount(count)); + } + }, [isUserSignedIn]); const registerAdminUpdate = useCallback(() => setAdminUpdateTimes(updates => [...updates, new Date()]) @@ -54,29 +68,33 @@ export default function AppStateProvider({ return ( setNextPhotoAnimation?.(undefined), shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands, isCommandKOpen, setIsCommandKOpen, + // ADMIN + userEmail, + setUserEmail, + isUserSignedIn, adminUpdateTimes, registerAdminUpdate, - shouldShowBaselineGrid, - shouldDebugBlur, + hiddenPhotosCount, + // DEBUG + arePhotosMatted, + setArePhotosMatted, setShouldDebugBlur, setShouldShowBaselineGrid, - clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), + shouldShowBaselineGrid, + shouldDebugBlur, }} > {children} diff --git a/src/tag/HiddenTag.tsx b/src/tag/HiddenTag.tsx index ba8e673a..e9b176f2 100644 --- a/src/tag/HiddenTag.tsx +++ b/src/tag/HiddenTag.tsx @@ -19,7 +19,10 @@ export default function HiddenTag({ label={badged ? {TAG_HIDDEN} - + : TAG_HIDDEN} href={pathForTag(TAG_HIDDEN)} diff --git a/src/tag/index.ts b/src/tag/index.ts index a1dc3bbd..fc85ee32 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -91,3 +91,14 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs); export const isPathFavs = (pathname?: string) => getPathComponents(pathname).tag === TAG_FAVS; + +export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => { + if (hiddenPhotosCount > 0) { + return tags + .filter(({ tag }) => tag === TAG_FAVS) + .concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount }) + .concat(tags.filter(({ tag }) => tag !== TAG_FAVS)); + } else { + return tags; + } +}; From e00d6ad62a7e6f9f13589f0b40b8612f0aa25157 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 19:02:53 -0500 Subject: [PATCH 5/8] Anonymize photo upload storage urls --- src/photo/actions.ts | 10 ++++++- src/services/storage/index.ts | 51 ++++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 80f03e77..7757a4e2 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -49,7 +49,7 @@ export const createPhotoAction = async (formData: FormData) => safelyRunAdminServerAction(async () => { const photo = convertFormDataToPhotoDbInsert(formData, true); - const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); + const updatedUrl = await convertUploadToPhoto(photo.url); if (updatedUrl) { photo.url = updatedUrl; } @@ -64,6 +64,14 @@ export const updatePhotoAction = async (formData: FormData) => safelyRunAdminServerAction(async () => { const photo = convertFormDataToPhotoDbInsert(formData); + let url: string | undefined; + if (photo.hidden && photo.url.includes(photo.id)) { + // Anonymize storage url on update if necessary by + // re-running image upload transfer logic + url = await convertUploadToPhoto(photo.url); + if (url) { photo.url = url; } + } + await sqlUpdatePhoto(photo); revalidatePhoto(photo.id); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 0f3e008e..481cff36 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -129,53 +129,66 @@ export const uploadPhotoFromClient = async ( ? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true) : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); -export const convertUploadToPhoto = async ( - uploadUrl: string, - photoId?: string, -): Promise => { - const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; - const fileExtension = getExtensionFromStorageUrl(uploadUrl); - const photoPath = `${fileName}.${fileExtension ?? 'jpg'}`; - - const storageType = storageTypeFromUrl(uploadUrl); +const moveFile = async ( + originUrl: string, + destinationFileName: string, +) => { + const storageType = storageTypeFromUrl(originUrl); let url: string | undefined; // Copy file switch (storageType) { case 'vercel-blob': - url = await vercelBlobCopy(uploadUrl, photoPath, photoId === undefined); + url = await vercelBlobCopy( + originUrl, + destinationFileName, + false, + ); break; case 'cloudflare-r2': url = await cloudflareR2Copy( - getFileNameFromStorageUrl(uploadUrl), - photoPath, - photoId === undefined, + getFileNameFromStorageUrl(originUrl), + destinationFileName, + false, ); break; case 'aws-s3': - url = await awsS3Copy(uploadUrl, photoPath, photoId === undefined); + url = await awsS3Copy( + originUrl, + destinationFileName, + false, + ); break; } - + // If successful, delete original file if (url) { switch (storageType) { case 'vercel-blob': - await vercelBlobDelete(uploadUrl); + await vercelBlobDelete(originUrl); break; case 'cloudflare-r2': - await cloudflareR2Delete(getFileNameFromStorageUrl(uploadUrl)); + await cloudflareR2Delete(getFileNameFromStorageUrl(originUrl)); break; case 'aws-s3': - await awsS3Delete(getFileNameFromStorageUrl(uploadUrl)); + await awsS3Delete(getFileNameFromStorageUrl(originUrl)); break; } } - + return url; }; +export const convertUploadToPhoto = async ( + urlOrigin: string, +): Promise => { + const fileName = `${PREFIX_PHOTO}-${generateStorageId()}`; + const fileExtension = getExtensionFromStorageUrl(urlOrigin); + const photoPath = `${fileName}.${fileExtension || 'jpg'}`; + return moveFile(urlOrigin, photoPath); +}; + export const deleteStorageUrl = (url: string) => { switch (storageTypeFromUrl(url)) { case 'vercel-blob': From 28a8e84eafc5e9c94a4bc123f23d48ab9f1df67b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 22:12:45 -0500 Subject: [PATCH 6/8] Add hidden photos to README FAQ --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 44d4023a..7add19c6 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ FAQ #### Why do my vertical images take up so much space? > By default, all photos are shown full-width, regardless of orientation. Enable matting to showcase horizontal and vertical photos at a similar scale by setting `NEXT_PUBLIC_MATTE_PHOTOS = 1`. +#### How secure are photos marked “hidden?” +> While all hidden paths (`/tag/hidden/*`) require authentication, raw links to individual photo files remain publicly accessible. Their randomly generated file names are only secure via obscurity. + #### My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do? > Navigate to `/admin/configuration` and click "Clear Cache." From d985ec03dfeac25222d6baaed0bd48bd9b32f056 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 22:46:32 -0500 Subject: [PATCH 7/8] Add default banner icon --- src/components/Banner.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index bab0b631..db738a84 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import InfoBlock from './InfoBlock'; import AnimateItems from './AnimateItems'; +import { IoInformationCircleOutline } from 'react-icons/io5'; export default function Banner({ icon, @@ -24,8 +25,11 @@ export default function Banner({ padding="tight" color="blue" > -
- {icon} +
+ {icon ?? } {children}
, From 89036727363f8350087f27bafc6e4644388c1993 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 12 May 2024 22:51:53 -0500 Subject: [PATCH 8/8] Manage hidden photo count requests --- src/state/AppStateProvider.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 524053be..e6bafb2e 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -49,11 +49,16 @@ export default function AppStateProvider({ setUserEmail(data?.user?.email ?? undefined); logClientAuthUpdate(data); }, [data]); - const isUserSignedIn = userEmail !== undefined; + const isUserSignedIn = Boolean(userEmail); useEffect(() => { if (isUserSignedIn) { - getPhotosTagHiddenMetaCachedAction().then(({ count }) => - setHiddenPhotosCount(count)); + const timeout = setTimeout(() => + getPhotosTagHiddenMetaCachedAction().then(({ count }) => + setHiddenPhotosCount(count)) + , 100); + return () => clearTimeout(timeout); + } else { + setHiddenPhotosCount(0); } }, [isUserSignedIn]);