Refactor paths/escape handling, add test coverage

This commit is contained in:
Sam Becker 2023-09-29 13:40:08 -05:00
parent f126f4b0a7
commit 3d279cdab5
22 changed files with 2106 additions and 83 deletions

View File

@ -14,6 +14,7 @@
"thephotoblog", "thephotoblog",
"trpc", "trpc",
"unnest", "unnest",
"UsKSGcbt",
"WRHGZC", "WRHGZC",
"zadd", "zadd",
"zrange" "zrange"

81
__tests__/path.test.ts Normal file
View File

@ -0,0 +1,81 @@
import '@testing-library/jest-dom';
import {
getEscapePath,
getPathComponents,
isPathPhoto,
isPathPhotoShare,
isPathTag,
isPathTagPhoto,
isPathTagPhotoShare,
isPathTagShare,
} from '@/site/paths';
const PHOTO_ID = 'UsKSGcbt';
const TAG = 'tag-name';
const SHARE = 'share';
const PATH_ROOT = '/';
const PATH_GRID = '/grid';
const PATH_ADMIN = '/admin/photos';
const PATH_PHOTO = `/p/${PHOTO_ID}`;
const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`;
const PATH_TAG = `/t/${TAG}`;
const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`;
const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`;
const PATH_TAG_PHOTO_SHARE = `${PATH_TAG}/${PHOTO_ID}/${SHARE}`;
describe('Paths', () => {
it('can be classified', () => {
// Positive
expect(isPathPhoto(PATH_PHOTO)).toBe(true);
expect(isPathPhotoShare(PATH_PHOTO_SHARE)).toBe(true);
expect(isPathTag(PATH_TAG)).toBe(true);
expect(isPathTagShare(PATH_TAG_SHARE)).toBe(true);
expect(isPathTagPhoto(PATH_TAG_PHOTO)).toBe(true);
expect(isPathTagPhotoShare(PATH_TAG_PHOTO_SHARE)).toBe(true);
// Negative
expect(isPathPhoto(PATH_TAG_PHOTO_SHARE)).toBe(false);
expect(isPathPhotoShare(PATH_TAG_PHOTO)).toBe(false);
expect(isPathTag(PATH_TAG_SHARE)).toBe(false);
expect(isPathTagShare(PATH_TAG)).toBe(false);
expect(isPathTagPhoto(PATH_PHOTO_SHARE)).toBe(false);
expect(isPathTagPhotoShare(PATH_PHOTO)).toBe(false);
});
it('can be parsed', () => {
expect(getPathComponents(PATH_ROOT)).toEqual({});
expect(getPathComponents(PATH_PHOTO)).toEqual({
photoId: PHOTO_ID,
});
expect(getPathComponents(PATH_PHOTO_SHARE)).toEqual({
photoId: PHOTO_ID,
});
expect(getPathComponents(PATH_TAG)).toEqual({
tag: TAG,
});
expect(getPathComponents(PATH_TAG_SHARE)).toEqual({
tag: TAG,
});
expect(getPathComponents(PATH_TAG_PHOTO)).toEqual({
photoId: PHOTO_ID,
tag: TAG,
});
expect(getPathComponents(PATH_TAG_PHOTO_SHARE)).toEqual({
photoId: PHOTO_ID,
tag: TAG,
});
});
it('can be escaped', () => {
// Root views
expect(getEscapePath(PATH_ROOT)).toEqual(undefined);
expect(getEscapePath(PATH_GRID)).toEqual(undefined);
expect(getEscapePath(PATH_ADMIN)).toEqual(undefined);
// Photo views
expect(getEscapePath(PATH_PHOTO)).toEqual(PATH_GRID);
expect(getEscapePath(PATH_PHOTO_SHARE)).toEqual(PATH_PHOTO);
// Tag views
expect(getEscapePath(PATH_TAG)).toEqual(PATH_GRID);
expect(getEscapePath(PATH_TAG_SHARE)).toEqual(PATH_TAG);
expect(getEscapePath(PATH_TAG_PHOTO)).toEqual(PATH_TAG);
expect(getEscapePath(PATH_TAG_PHOTO_SHARE)).toEqual(PATH_TAG_PHOTO);
});
});

19
jest.config.mjs Normal file
View File

@ -0,0 +1,19 @@
/* eslint-disable max-len */
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);

View File

@ -5,11 +5,15 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "jest --watch",
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@next/bundle-analyzer": "^13.5.3", "@next/bundle-analyzer": "^13.5.3",
"@tailwindcss/forms": "^0.5.6", "@tailwindcss/forms": "^0.5.6",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.5",
"@types/node": "^20.7.1", "@types/node": "^20.7.1",
"@types/react": "18.2.23", "@types/react": "18.2.23",
"@types/react-dom": "18.2.8", "@types/react-dom": "18.2.8",
@ -24,6 +28,8 @@
"eslint": "8.50.0", "eslint": "8.50.0",
"eslint-config-next": "13.5.3", "eslint-config-next": "13.5.3",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.1", "nanoid": "^5.0.1",
"next": "^13.5.3", "next": "^13.5.3",
"next-auth": "0.0.0-manual.5749b095", "next-auth": "0.0.0-manual.5749b095",

1796
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,11 @@ import {
import { FaRegEdit } from 'react-icons/fa'; import { FaRegEdit } from 'react-icons/fa';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { pathForBlobUrl } from '@/services/blob'; import { pathForBlobUrl } from '@/services/blob';
import { pathForPhoto, pathForPhotoEdit } from '@/site/paths'; import {
pathForAdminPhotos,
pathForPhoto,
pathForPhotoEdit,
} from '@/site/paths';
import { getPhotosLimitForQuery, titleForPhoto } from '@/photo'; import { getPhotosLimitForQuery, titleForPhoto } from '@/photo';
import MorePhotos from '@/components/MorePhotos'; import MorePhotos from '@/components/MorePhotos';
import { import {
@ -123,7 +127,7 @@ export default async function AdminPage({
</Fragment>)} </Fragment>)}
</AdminGrid> </AdminGrid>
{showMorePhotos && {showMorePhotos &&
<MorePhotos path={`/admin/photos?next=${offset + 1}`} />} <MorePhotos path={pathForAdminPhotos(offset + 1)} />}
</div> </div>
</div> </div>
</div>} </div>}

View File

@ -10,6 +10,7 @@ import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid'; import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response'; import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
import { pathForGrid } from '@/site/paths';
import PhotoTag from '@/tag/PhotoTag'; import PhotoTag from '@/tag/PhotoTag';
import { Metadata } from 'next'; import { Metadata } from 'next';
@ -45,7 +46,7 @@ export default async function GridPage({
contentMain={<div className="space-y-4"> contentMain={<div className="space-y-4">
<PhotoGrid photos={photos} /> <PhotoGrid photos={photos} />
{showMorePhotos && {showMorePhotos &&
<MorePhotos path={`/grid?next=${offset + 1}`} />} <MorePhotos path={pathForGrid(offset + 1)} />}
</div>} </div>}
contentSide={tags && contentSide={tags &&
<AnimateItems <AnimateItems

View File

@ -2,6 +2,7 @@ import { getPhotosCached, getPhotosCountCached } from '@/cache';
import MorePhotos from '@/components/MorePhotos'; import MorePhotos from '@/components/MorePhotos';
import { getPhotosLimitForQuery } from '@/photo'; import { getPhotosLimitForQuery } from '@/photo';
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos'; import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
import { pathForOg } from '@/site/paths';
export const runtime = 'edge'; export const runtime = 'edge';
@ -28,7 +29,7 @@ export default async function GridPage({
<StaggeredOgPhotos photos={photos} /> <StaggeredOgPhotos photos={photos} />
</div> </div>
{showMorePhotos && {showMorePhotos &&
<MorePhotos path={`/og?next=${offset + 1}`} />} <MorePhotos path={pathForOg(offset + 1)} />}
</div> </div>
); );
} }

View File

@ -5,7 +5,11 @@ import {
} from '@/photo'; } from '@/photo';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths'; import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage'; import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache'; import { getPhotoCached, getPhotosCached } from '@/cache';
import { getPhotos } from '@/services/postgres'; import { getPhotos } from '@/services/postgres';
@ -58,7 +62,7 @@ export default async function PhotoPage({
}) { }) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { redirect('/'); } if (!photo) { redirect(PATH_ROOT); }
const [ const [
photosBefore, photosBefore,

View File

@ -1,5 +1,6 @@
import { getPhotoCached } from '@/cache'; import { getPhotoCached } from '@/cache';
import PhotoShareModal from '@/photo/PhotoShareModal'; import PhotoShareModal from '@/photo/PhotoShareModal';
import { PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export const runtime = 'edge'; export const runtime = 'edge';
@ -11,7 +12,7 @@ export default async function Share({
}) { }) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { return redirect('/'); } if (!photo) { return redirect(PATH_ROOT); }
return <PhotoShareModal photo={photo} />; return <PhotoShareModal photo={photo} />;
} }

View File

@ -5,6 +5,7 @@ import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo'; import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoLarge from '@/photo/PhotoLarge'; import PhotoLarge from '@/photo/PhotoLarge';
import PhotosEmptyState from '@/photo/PhotosEmptyState'; import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { pathForRoot } from '@/site/paths';
import { Metadata } from 'next'; import { Metadata } from 'next';
export const runtime = 'edge'; export const runtime = 'edge';
@ -49,7 +50,7 @@ export default async function HomePage({
/> />
{showMorePhotos && {showMorePhotos &&
<SiteGrid <SiteGrid
contentMain={<MorePhotos path={`?next=${offset + 1}`} />} contentMain={<MorePhotos path={pathForRoot(offset + 1)} />}
/>} />}
</div> </div>
: <PhotosEmptyState /> : <PhotosEmptyState />

View File

@ -4,7 +4,11 @@ import {
} from '@/photo'; } from '@/photo';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths'; import {
PATH_ROOT,
absolutePathForPhoto,
absolutePathForPhotoImage,
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage'; import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotoCached, getPhotosCached } from '@/cache'; import { getPhotoCached, getPhotosCached } from '@/cache';
import { getPhotos, getUniqueTags } from '@/services/postgres'; import { getPhotos, getUniqueTags } from '@/services/postgres';
@ -64,7 +68,7 @@ export default async function PhotoTagPage({
}) { }) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { redirect('/'); } if (!photo) { redirect(PATH_ROOT); }
const photos = await getPhotosCached({ tag }); const photos = await getPhotosCached({ tag });

View File

@ -1,6 +1,7 @@
import { getPhotoCached } from '@/cache'; import { getPhotoCached } from '@/cache';
import PhotoShareModal from '@/photo/PhotoShareModal'; import PhotoShareModal from '@/photo/PhotoShareModal';
import { getPhotos, getUniqueTags } from '@/services/postgres'; import { getPhotos, getUniqueTags } from '@/services/postgres';
import { PATH_ROOT } from '@/site/paths';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export async function generateStaticParams() { export async function generateStaticParams() {
@ -24,7 +25,7 @@ export default async function Share({
}) { }) {
const photo = await getPhotoCached(photoId); const photo = await getPhotoCached(photoId);
if (!photo) { return redirect('/'); } if (!photo) { return redirect(PATH_ROOT); }
return <PhotoShareModal photo={photo} tag={tag} />; return <PhotoShareModal photo={photo} tag={tag} />;
} }

View File

@ -7,6 +7,7 @@ import StateProvider from '@/state/AppStateProvider';
import ThemeProviderClient from '@/site/ThemeProviderClient'; import ThemeProviderClient from '@/site/ThemeProviderClient';
import Nav from '@/components/Nav'; import Nav from '@/components/Nav';
import ToasterWithThemes from '@/components/ToasterWithThemes'; import ToasterWithThemes from '@/components/ToasterWithThemes';
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
import '../site/globals.css'; import '../site/globals.css';
@ -77,6 +78,7 @@ export default function RootLayout({
</StateProvider> </StateProvider>
<Analytics /> <Analytics />
</main> </main>
<PhotoEscapeHandler />
<ToasterWithThemes /> <ToasterWithThemes />
</ThemeProviderClient> </ThemeProviderClient>
</body> </body>

View File

@ -1,5 +1,6 @@
import { auth } from '@/auth'; import { auth } from '@/auth';
import SignInForm from '@/auth/SignInForm'; import SignInForm from '@/auth/SignInForm';
import { PATH_ROOT } from '@/site/paths';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -9,7 +10,7 @@ export default async function SignInPage() {
const session = await auth(); const session = await auth();
if (session?.user) { if (session?.user) {
redirect('/'); redirect(PATH_ROOT);
} }
return ( return (

View File

@ -6,6 +6,7 @@ import { cc } from '@/utility/css';
import useClickInsideOutside from '@/utility/useClickInsideOutside'; import useClickInsideOutside from '@/utility/useClickInsideOutside';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AnimateItems from './AnimateItems'; import AnimateItems from './AnimateItems';
import { PATH_ROOT } from '@/site/paths';
export default function Modal({ export default function Modal({
onClosePath, onClosePath,
@ -28,7 +29,10 @@ export default function Modal({
useClickInsideOutside({ useClickInsideOutside({
htmlElements, htmlElements,
onClickOutside: () => router.push(onClosePath ?? '/', { scroll: false}), onClickOutside: () => router.push(
onClosePath ?? PATH_ROOT,
{ scroll: false },
),
}); });
return ( return (

View File

@ -6,6 +6,12 @@ import Link from 'next/link';
import SiteGrid from './SiteGrid'; import SiteGrid from './SiteGrid';
import { SITE_DOMAIN_OR_TITLE } from '@/site/config'; import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
import ViewSwitcher, { SwitcherSelection } from '@/photo/ViewSwitcher'; import ViewSwitcher, { SwitcherSelection } from '@/photo/ViewSwitcher';
import {
PATH_ADMIN,
PATH_ROOT,
isPathGrid,
isPathProtected,
} from '@/site/paths';
export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) { export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
const isLoggedIn = false; const isLoggedIn = false;
@ -23,11 +29,11 @@ export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
: <button onClick={linkOrAction}>{text}</button>; : <button onClick={linkOrAction}>{text}</button>;
const switcherSelectionForPath = (): SwitcherSelection | undefined => { const switcherSelectionForPath = (): SwitcherSelection | undefined => {
if (pathname === '/') { if (pathname === PATH_ROOT) {
return 'full-frame'; return 'full-frame';
} else if (pathname === '/grid') { } else if (isPathGrid(pathname)) {
return 'grid'; return 'grid';
} else if (pathname.startsWith('/admin')) { } else if (isPathProtected(pathname)) {
return 'admin'; return 'admin';
} }
}; };
@ -47,12 +53,12 @@ export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
showAdmin={isLoggedIn} showAdmin={isLoggedIn}
/> />
{showTextLinks && <> {showTextLinks && <>
{renderLink('Home', '/')} {renderLink('Home', PATH_ROOT)}
{renderLink('Admin', '/admin')} {renderLink('Admin', PATH_ADMIN)}
</>} </>}
</div> </div>
<div className="hidden xs:block"> <div className="hidden xs:block">
{renderLink(SITE_DOMAIN_OR_TITLE, '/')} {renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
</div> </div>
</>} </>}
</div> </div>

View File

@ -0,0 +1,27 @@
'use client';
import { getEscapePath } from '@/site/paths';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
const LISTENER_KEYUP = 'keyup';
export default function PhotoEscapeHandler() {
const router = useRouter();
const pathname = usePathname();
const escapePath = getEscapePath(pathname);
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
if (e.key.toUpperCase() === 'ESCAPE' && escapePath) {
router.push(escapePath, { scroll: false });
};
};
window.addEventListener(LISTENER_KEYUP, onKeyUp);
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
}, [router, escapePath]);
return null;
}

View File

@ -2,61 +2,44 @@ import { Photo } from '.';
import PhotoSmall from './PhotoSmall'; import PhotoSmall from './PhotoSmall';
import { cc } from '@/utility/css'; import { cc } from '@/utility/css';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import Link from 'next/link';
const PHOTOS_PER_PAGE = 6;
const PHOTOS_MAX = 35;
export default function PhotoGrid({ export default function PhotoGrid({
photos, photos,
selectedPhoto, selectedPhoto,
tag, tag,
offset = 0,
fast, fast,
animate = true, animate = true,
animateOnFirstLoadOnly, animateOnFirstLoadOnly,
staggerOnFirstLoadOnly = true, staggerOnFirstLoadOnly = true,
showMore,
}: { }: {
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
tag?: string tag?: string
offset?: number
fast?: boolean fast?: boolean
animate?: boolean animate?: boolean
animateOnFirstLoadOnly?: boolean animateOnFirstLoadOnly?: boolean
staggerOnFirstLoadOnly?: boolean staggerOnFirstLoadOnly?: boolean
showMore?: boolean
}) { }) {
return ( return (
<> <AnimateItems
<AnimateItems className={cc(
className={cc( 'grid gap-1',
'grid gap-1', 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4', 'items-center',
'items-center', )}
)} type={animate === false ? 'none' : undefined}
type={animate === false ? 'none' : undefined} duration={fast ? 0.3 : undefined}
duration={fast ? 0.3 : undefined} staggerDelay={0.075}
staggerDelay={0.075} distanceOffset={40}
distanceOffset={40} animateOnFirstLoadOnly={animateOnFirstLoadOnly}
animateOnFirstLoadOnly={animateOnFirstLoadOnly} staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly} items={photos.map(photo =>
items={photos.map(photo => <PhotoSmall
<PhotoSmall key={photo.id}
key={photo.id} photo={photo}
photo={photo} tag={tag}
tag={tag} selected={photo.id === selectedPhoto?.id}
selected={photo.id === selectedPhoto?.id} />)}
/>)} />
/>
{showMore && (offset + PHOTOS_PER_PAGE) < PHOTOS_MAX &&
<Link
className="button mt-12"
href={`/grid/${offset + PHOTOS_PER_PAGE}`}
>
More
</Link>}
</>
); );
}; };

View File

@ -8,6 +8,8 @@ import { pathForPhoto } from '@/site/paths';
import { useAppState } from '@/state'; import { useAppState } from '@/state';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
const LISTENER_KEYUP = 'keyup';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 }; const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 }; const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
@ -44,14 +46,17 @@ export default function PhotoLinks({
router.push(pathForPhoto(nextPhoto, tag), { scroll: false }); router.push(pathForPhoto(nextPhoto, tag), { scroll: false });
} }
break; break;
case 'ESCAPE':
router.push('/grid');
break;
}; };
}; };
window.addEventListener('keyup', onKeyUp); window.addEventListener(LISTENER_KEYUP, onKeyUp);
return () => window.removeEventListener('keyup', onKeyUp); return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
}, [router, setNextPhotoAnimation, previousPhoto, nextPhoto, tag]); }, [
router,
setNextPhotoAnimation,
previousPhoto,
nextPhoto,
tag,
]);
return ( return (
<> <>

View File

@ -2,6 +2,7 @@ import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem'; import SwitcherItem from '@/components/SwitcherItem';
import IconFullFrame from '@/icons/IconFullFrame'; import IconFullFrame from '@/icons/IconFullFrame';
import IconGrid from '@/icons/IconGrid'; import IconGrid from '@/icons/IconGrid';
import { PATH_GRID } from '@/site/paths';
import { BiLockAlt } from 'react-icons/bi'; import { BiLockAlt } from 'react-icons/bi';
export type SwitcherSelection = 'full-frame' | 'grid' | 'admin'; export type SwitcherSelection = 'full-frame' | 'grid' | 'admin';
@ -23,7 +24,7 @@ export default function ViewSwitcher({
/> />
<SwitcherItem <SwitcherItem
icon={<IconGrid />} icon={<IconGrid />}
href="/grid" href={PATH_GRID}
active={currentSelection === 'grid'} active={currentSelection === 'grid'}
noPadding noPadding
/> />

View File

@ -1,55 +1,143 @@
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import { BASE_URL } from './config'; import { BASE_URL } from './config';
// Prefixes
const PREFIX_PHOTO = '/p'; const PREFIX_PHOTO = '/p';
const PREFIX_TAG = '/t'; const PREFIX_TAG = '/t';
const PREFIX_ADMIN = '/admin';
// Modifiers
const SHARE = 'share'; const SHARE = 'share';
const NEXT = 'next';
export const PATH_ADMIN_PHOTOS = `${PREFIX_ADMIN}/photos`; // Core paths
export const PATH_ADMIN_UPLOAD = `${PREFIX_ADMIN}/uploads`; export const PATH_ROOT = '/';
export const PATH_GRID = '/grid';
export const PATH_ADMIN = '/admin';
export const PATH_OG = '/og';
// Extended paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_UPLOAD = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_UPLOAD_BLOB_HANDLER = `${PATH_ADMIN_UPLOAD}/blob`; export const PATH_ADMIN_UPLOAD_BLOB_HANDLER = `${PATH_ADMIN_UPLOAD}/blob`;
// Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`; export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
export const pathForPhoto = (photo: Photo, tag?: string) => const pathWithNext = (path: string, next?: number) =>
tag next !== undefined ? `${path}?${NEXT}=${next}` : path;
? `${pathForTag(tag)}/${photo.id}`
: `${PREFIX_PHOTO}/${photo.id}`;
export const pathForPhotoShare = (photo: Photo, tag?: string) => export const pathForRoot = (next?: number) =>
pathWithNext(PATH_ROOT, next);
export const pathForGrid = (next?: number) =>
pathWithNext(PATH_GRID, next);
export const pathForAdminPhotos = (next?: number) =>
pathWithNext(PATH_ADMIN_PHOTOS, next);
export const pathForOg = (next?: number) =>
pathWithNext(PATH_OG, next);
type PhotoOrPhotoId = Photo | string;
const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
typeof photoOrPhotoId === 'string' ? photoOrPhotoId : photoOrPhotoId.id;
export const pathForPhoto = (photo: PhotoOrPhotoId, tag?: string) =>
tag
? `${pathForTag(tag)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
export const pathForPhotoShare = (photo: PhotoOrPhotoId, tag?: string) =>
`${pathForPhoto(photo, tag)}/${SHARE}`; `${pathForPhoto(photo, tag)}/${SHARE}`;
export const pathForPhotoEdit = (photo: Photo) => export const pathForPhotoEdit = (photo: PhotoOrPhotoId) =>
`${PATH_ADMIN_PHOTOS}/${photo.id}/edit`; `${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`;
export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`; export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`;
export const pathForTagShare = (tag: string) => export const pathForTagShare = (tag: string) =>
`${pathForTag(tag)}/${SHARE}`; `${pathForTag(tag)}/${SHARE}`;
export const absolutePathForPhoto = (photo: Photo, tag?: string) => export const absolutePathForPhoto = (photo: PhotoOrPhotoId, tag?: string) =>
`${BASE_URL}${pathForPhoto(photo, tag)}`; `${BASE_URL}${pathForPhoto(photo, tag)}`;
export const absolutePathForTag = (tag: string) => export const absolutePathForTag = (tag: string) =>
`${BASE_URL}${pathForTag(tag)}`; `${BASE_URL}${pathForTag(tag)}`;
export const absolutePathForPhotoImage = (photo: Photo) => export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
`${absolutePathForPhoto(photo)}/image`; `${absolutePathForPhoto(photo)}/image`;
export const absolutePathForTagImage = (tag: string) => export const absolutePathForTagImage = (tag: string) =>
`${absolutePathForTag(tag)}/image`; `${absolutePathForTag(tag)}/image`;
// p/[photoId]
export const isPathPhoto = (pathname = '') => export const isPathPhoto = (pathname = '') =>
/^\/p\/[^/]+\/?$/.test(pathname); /^\/p\/[^/]+\/?$/.test(pathname);
// p/[photoId]/share
export const isPathPhotoShare = (pathname = '') => export const isPathPhotoShare = (pathname = '') =>
/^\/p\/[^/]+\/share\/?$/.test(pathname); /^\/p\/[^/]+\/share\/?$/.test(pathname);
// t/[tagId]
export const isPathTag = (pathname = '') =>
/^\/t\/[^/]+\/?$/.test(pathname);
// t/[tagId]/share
export const isPathTagShare = (pathname = '') =>
/^\/t\/[^/]+\/share\/?$/.test(pathname);
// t/[tagId]/[photoId]
export const isPathTagPhoto = (pathname = '') =>
/^\/t\/[^/]+\/[^/]+\/?$/.test(pathname);
// t/[tagId]/[photoId]/share
export const isPathTagPhotoShare = (pathname = '') =>
/^\/t\/[^/]+\/[^/]+\/share\/?$/.test(pathname);
export const isPathGrid = (pathname = '') =>
pathname.startsWith(PATH_GRID);
export const isPathSignIn = (pathname = '') => export const isPathSignIn = (pathname = '') =>
pathname.startsWith('/sign-in'); pathname.startsWith('/sign-in');
export const isPathAdmin = (pathname = '') =>
pathname.startsWith('/admin');
export const isPathProtected = (pathname = '') => export const isPathProtected = (pathname = '') =>
pathname.startsWith(PREFIX_ADMIN) || pathname.startsWith(PATH_ADMIN) ||
pathname === '/checklist'; pathname === '/checklist';
export const getPathComponents = (pathname = ''): {
photoId?: string
tag?: string
} => {
const photoIdFromPhoto = pathname.match(/^\/p\/([^/]+)/)?.[1];
const photoIdFromTag = pathname.match(/^\/t\/[^/]+\/((?!share)[^/]+)/)?.[1];
const tag = pathname.match(/^\/t\/([^/]+)/)?.[1];
return {
photoId: (
photoIdFromPhoto ||
photoIdFromTag
),
tag,
};
};
export const getEscapePath = (pathname?: string) => {
const { photoId, tag } = getPathComponents(pathname);
if (
(photoId && isPathPhoto(pathname)) ||
(tag && isPathTag(pathname))
) {
return PATH_GRID;
} else if (photoId && isPathTagPhotoShare(pathname)) {
return pathForPhoto(photoId, tag);
} else if (photoId && isPathPhotoShare(pathname)) {
return pathForPhoto(photoId);
} else if (tag && (isPathTagPhoto(pathname) || isPathTagShare(pathname))) {
return pathForTag(tag);
} else if (tag && isPathTagShare(pathname)) {
return pathForTag(tag);
}
};