Create protected hidden routes for admins
This commit is contained in:
parent
fe7a016491
commit
c0f4f1fbf1
@ -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);
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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 detected—they may take several minutes to show upe
|
||||||
<FaRegClock className="flex-shrink-0" />
|
for visitors
|
||||||
Photo updates detected—they may take several minutes to show up
|
</Banner>}
|
||||||
for visitors
|
|
||||||
</div>
|
|
||||||
</InfoBlock>}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|
||||||
|
|||||||
@ -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(() => []),
|
||||||
|
|||||||
@ -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 }} />
|
||||||
);
|
);
|
||||||
|
|||||||
58
src/app/tag/hidden/[photoId]/page.tsx
Normal file
58
src/app/tag/hidden/[photoId]/page.tsx
Normal 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 }} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
69
src/app/tag/hidden/page.tsx
Normal file
69
src/app/tag/hidden/page.tsx
Normal 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
36
src/components/Banner.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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]'
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 = () =>
|
||||||
|
|||||||
@ -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 = ({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
23
src/tag/HiddenHeader.tsx
Normal 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
34
src/tag/HiddenTag.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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)}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user