Refactor paths/escape handling, add test coverage
This commit is contained in:
parent
f126f4b0a7
commit
3d279cdab5
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -14,6 +14,7 @@
|
||||
"thephotoblog",
|
||||
"trpc",
|
||||
"unnest",
|
||||
"UsKSGcbt",
|
||||
"WRHGZC",
|
||||
"zadd",
|
||||
"zrange"
|
||||
|
||||
81
__tests__/path.test.ts
Normal file
81
__tests__/path.test.ts
Normal 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
19
jest.config.mjs
Normal 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);
|
||||
@ -5,11 +5,15 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest --watch",
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^13.5.3",
|
||||
"@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/react": "18.2.23",
|
||||
"@types/react-dom": "18.2.8",
|
||||
@ -24,6 +28,8 @@
|
||||
"eslint": "8.50.0",
|
||||
"eslint-config-next": "13.5.3",
|
||||
"framer-motion": "^10.16.4",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"nanoid": "^5.0.1",
|
||||
"next": "^13.5.3",
|
||||
"next-auth": "0.0.0-manual.5749b095",
|
||||
|
||||
1796
pnpm-lock.yaml
generated
1796
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,11 @@ import {
|
||||
import { FaRegEdit } from 'react-icons/fa';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { pathForBlobUrl } from '@/services/blob';
|
||||
import { pathForPhoto, pathForPhotoEdit } from '@/site/paths';
|
||||
import {
|
||||
pathForAdminPhotos,
|
||||
pathForPhoto,
|
||||
pathForPhotoEdit,
|
||||
} from '@/site/paths';
|
||||
import { getPhotosLimitForQuery, titleForPhoto } from '@/photo';
|
||||
import MorePhotos from '@/components/MorePhotos';
|
||||
import {
|
||||
@ -123,7 +127,7 @@ export default async function AdminPage({
|
||||
</Fragment>)}
|
||||
</AdminGrid>
|
||||
{showMorePhotos &&
|
||||
<MorePhotos path={`/admin/photos?next=${offset + 1}`} />}
|
||||
<MorePhotos path={pathForAdminPhotos(offset + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@ -10,6 +10,7 @@ import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
|
||||
import { pathForGrid } from '@/site/paths';
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
@ -45,7 +46,7 @@ export default async function GridPage({
|
||||
contentMain={<div className="space-y-4">
|
||||
<PhotoGrid photos={photos} />
|
||||
{showMorePhotos &&
|
||||
<MorePhotos path={`/grid?next=${offset + 1}`} />}
|
||||
<MorePhotos path={pathForGrid(offset + 1)} />}
|
||||
</div>}
|
||||
contentSide={tags &&
|
||||
<AnimateItems
|
||||
|
||||
@ -2,6 +2,7 @@ import { getPhotosCached, getPhotosCountCached } from '@/cache';
|
||||
import MorePhotos from '@/components/MorePhotos';
|
||||
import { getPhotosLimitForQuery } from '@/photo';
|
||||
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
|
||||
import { pathForOg } from '@/site/paths';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -28,7 +29,7 @@ export default async function GridPage({
|
||||
<StaggeredOgPhotos photos={photos} />
|
||||
</div>
|
||||
{showMorePhotos &&
|
||||
<MorePhotos path={`/og?next=${offset + 1}`} />}
|
||||
<MorePhotos path={pathForOg(offset + 1)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,11 @@ import {
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next';
|
||||
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 { getPhotoCached, getPhotosCached } from '@/cache';
|
||||
import { getPhotos } from '@/services/postgres';
|
||||
@ -58,7 +62,7 @@ export default async function PhotoPage({
|
||||
}) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { redirect('/'); }
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const [
|
||||
photosBefore,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { getPhotoCached } from '@/cache';
|
||||
import PhotoShareModal from '@/photo/PhotoShareModal';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -11,7 +12,7 @@ export default async function Share({
|
||||
}) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { return redirect('/'); }
|
||||
if (!photo) { return redirect(PATH_ROOT); }
|
||||
|
||||
return <PhotoShareModal photo={photo} />;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import SiteGrid from '@/components/SiteGrid';
|
||||
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
|
||||
import PhotoLarge from '@/photo/PhotoLarge';
|
||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||
import { pathForRoot } from '@/site/paths';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -49,7 +50,7 @@ export default async function HomePage({
|
||||
/>
|
||||
{showMorePhotos &&
|
||||
<SiteGrid
|
||||
contentMain={<MorePhotos path={`?next=${offset + 1}`} />}
|
||||
contentMain={<MorePhotos path={pathForRoot(offset + 1)} />}
|
||||
/>}
|
||||
</div>
|
||||
: <PhotosEmptyState />
|
||||
|
||||
@ -4,7 +4,11 @@ import {
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next';
|
||||
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 { getPhotoCached, getPhotosCached } from '@/cache';
|
||||
import { getPhotos, getUniqueTags } from '@/services/postgres';
|
||||
@ -64,7 +68,7 @@ export default async function PhotoTagPage({
|
||||
}) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { redirect('/'); }
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const photos = await getPhotosCached({ tag });
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { getPhotoCached } from '@/cache';
|
||||
import PhotoShareModal from '@/photo/PhotoShareModal';
|
||||
import { getPhotos, getUniqueTags } from '@/services/postgres';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
@ -24,7 +25,7 @@ export default async function Share({
|
||||
}) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { return redirect('/'); }
|
||||
if (!photo) { return redirect(PATH_ROOT); }
|
||||
|
||||
return <PhotoShareModal photo={photo} tag={tag} />;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import StateProvider from '@/state/AppStateProvider';
|
||||
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
||||
import Nav from '@/components/Nav';
|
||||
import ToasterWithThemes from '@/components/ToasterWithThemes';
|
||||
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
||||
|
||||
import '../site/globals.css';
|
||||
|
||||
@ -77,6 +78,7 @@ export default function RootLayout({
|
||||
</StateProvider>
|
||||
<Analytics />
|
||||
</main>
|
||||
<PhotoEscapeHandler />
|
||||
<ToasterWithThemes />
|
||||
</ThemeProviderClient>
|
||||
</body>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { auth } from '@/auth';
|
||||
import SignInForm from '@/auth/SignInForm';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import { cc } from '@/utility/css';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
@ -9,7 +10,7 @@ export default async function SignInPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (session?.user) {
|
||||
redirect('/');
|
||||
redirect(PATH_ROOT);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -6,6 +6,7 @@ import { cc } from '@/utility/css';
|
||||
import useClickInsideOutside from '@/utility/useClickInsideOutside';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AnimateItems from './AnimateItems';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
|
||||
export default function Modal({
|
||||
onClosePath,
|
||||
@ -28,7 +29,10 @@ export default function Modal({
|
||||
|
||||
useClickInsideOutside({
|
||||
htmlElements,
|
||||
onClickOutside: () => router.push(onClosePath ?? '/', { scroll: false}),
|
||||
onClickOutside: () => router.push(
|
||||
onClosePath ?? PATH_ROOT,
|
||||
{ scroll: false },
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@ -6,6 +6,12 @@ import Link from 'next/link';
|
||||
import SiteGrid from './SiteGrid';
|
||||
import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
|
||||
import ViewSwitcher, { SwitcherSelection } from '@/photo/ViewSwitcher';
|
||||
import {
|
||||
PATH_ADMIN,
|
||||
PATH_ROOT,
|
||||
isPathGrid,
|
||||
isPathProtected,
|
||||
} from '@/site/paths';
|
||||
|
||||
export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
|
||||
const isLoggedIn = false;
|
||||
@ -23,11 +29,11 @@ export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
|
||||
: <button onClick={linkOrAction}>{text}</button>;
|
||||
|
||||
const switcherSelectionForPath = (): SwitcherSelection | undefined => {
|
||||
if (pathname === '/') {
|
||||
if (pathname === PATH_ROOT) {
|
||||
return 'full-frame';
|
||||
} else if (pathname === '/grid') {
|
||||
} else if (isPathGrid(pathname)) {
|
||||
return 'grid';
|
||||
} else if (pathname.startsWith('/admin')) {
|
||||
} else if (isPathProtected(pathname)) {
|
||||
return 'admin';
|
||||
}
|
||||
};
|
||||
@ -47,12 +53,12 @@ export default function Nav({ showTextLinks }: { showTextLinks?: boolean }) {
|
||||
showAdmin={isLoggedIn}
|
||||
/>
|
||||
{showTextLinks && <>
|
||||
{renderLink('Home', '/')}
|
||||
{renderLink('Admin', '/admin')}
|
||||
{renderLink('Home', PATH_ROOT)}
|
||||
{renderLink('Admin', PATH_ADMIN)}
|
||||
</>}
|
||||
</div>
|
||||
<div className="hidden xs:block">
|
||||
{renderLink(SITE_DOMAIN_OR_TITLE, '/')}
|
||||
{renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
27
src/photo/PhotoEscapeHandler.tsx
Normal file
27
src/photo/PhotoEscapeHandler.tsx
Normal 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;
|
||||
}
|
||||
@ -2,34 +2,25 @@ import { Photo } from '.';
|
||||
import PhotoSmall from './PhotoSmall';
|
||||
import { cc } from '@/utility/css';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import Link from 'next/link';
|
||||
|
||||
const PHOTOS_PER_PAGE = 6;
|
||||
const PHOTOS_MAX = 35;
|
||||
|
||||
export default function PhotoGrid({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
tag,
|
||||
offset = 0,
|
||||
fast,
|
||||
animate = true,
|
||||
animateOnFirstLoadOnly,
|
||||
staggerOnFirstLoadOnly = true,
|
||||
showMore,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
tag?: string
|
||||
offset?: number
|
||||
fast?: boolean
|
||||
animate?: boolean
|
||||
animateOnFirstLoadOnly?: boolean
|
||||
staggerOnFirstLoadOnly?: boolean
|
||||
showMore?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AnimateItems
|
||||
className={cc(
|
||||
'grid gap-1',
|
||||
@ -50,13 +41,5 @@ export default function PhotoGrid({
|
||||
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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,6 +8,8 @@ import { pathForPhoto } from '@/site/paths';
|
||||
import { useAppState } from '@/state';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
|
||||
const LISTENER_KEYUP = 'keyup';
|
||||
|
||||
const ANIMATION_LEFT: AnimationConfig = { type: 'left', 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 });
|
||||
}
|
||||
break;
|
||||
case 'ESCAPE':
|
||||
router.push('/grid');
|
||||
break;
|
||||
};
|
||||
};
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
return () => window.removeEventListener('keyup', onKeyUp);
|
||||
}, [router, setNextPhotoAnimation, previousPhoto, nextPhoto, tag]);
|
||||
window.addEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
}, [
|
||||
router,
|
||||
setNextPhotoAnimation,
|
||||
previousPhoto,
|
||||
nextPhoto,
|
||||
tag,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -2,6 +2,7 @@ import Switcher from '@/components/Switcher';
|
||||
import SwitcherItem from '@/components/SwitcherItem';
|
||||
import IconFullFrame from '@/icons/IconFullFrame';
|
||||
import IconGrid from '@/icons/IconGrid';
|
||||
import { PATH_GRID } from '@/site/paths';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
|
||||
export type SwitcherSelection = 'full-frame' | 'grid' | 'admin';
|
||||
@ -23,7 +24,7 @@ export default function ViewSwitcher({
|
||||
/>
|
||||
<SwitcherItem
|
||||
icon={<IconGrid />}
|
||||
href="/grid"
|
||||
href={PATH_GRID}
|
||||
active={currentSelection === 'grid'}
|
||||
noPadding
|
||||
/>
|
||||
|
||||
@ -1,55 +1,143 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { BASE_URL } from './config';
|
||||
|
||||
// Prefixes
|
||||
const PREFIX_PHOTO = '/p';
|
||||
const PREFIX_TAG = '/t';
|
||||
const PREFIX_ADMIN = '/admin';
|
||||
|
||||
// Modifiers
|
||||
const SHARE = 'share';
|
||||
const NEXT = 'next';
|
||||
|
||||
export const PATH_ADMIN_PHOTOS = `${PREFIX_ADMIN}/photos`;
|
||||
export const PATH_ADMIN_UPLOAD = `${PREFIX_ADMIN}/uploads`;
|
||||
// Core paths
|
||||
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`;
|
||||
|
||||
// Absolute paths
|
||||
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
|
||||
|
||||
export const pathForPhoto = (photo: Photo, tag?: string) =>
|
||||
tag
|
||||
? `${pathForTag(tag)}/${photo.id}`
|
||||
: `${PREFIX_PHOTO}/${photo.id}`;
|
||||
const pathWithNext = (path: string, next?: number) =>
|
||||
next !== undefined ? `${path}?${NEXT}=${next}` : path;
|
||||
|
||||
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}`;
|
||||
|
||||
export const pathForPhotoEdit = (photo: Photo) =>
|
||||
`${PATH_ADMIN_PHOTOS}/${photo.id}/edit`;
|
||||
export const pathForPhotoEdit = (photo: PhotoOrPhotoId) =>
|
||||
`${PATH_ADMIN_PHOTOS}/${getPhotoId(photo)}/edit`;
|
||||
|
||||
export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`;
|
||||
|
||||
export const pathForTagShare = (tag: string) =>
|
||||
`${pathForTag(tag)}/${SHARE}`;
|
||||
|
||||
export const absolutePathForPhoto = (photo: Photo, tag?: string) =>
|
||||
export const absolutePathForPhoto = (photo: PhotoOrPhotoId, tag?: string) =>
|
||||
`${BASE_URL}${pathForPhoto(photo, tag)}`;
|
||||
|
||||
export const absolutePathForTag = (tag: string) =>
|
||||
`${BASE_URL}${pathForTag(tag)}`;
|
||||
|
||||
export const absolutePathForPhotoImage = (photo: Photo) =>
|
||||
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
||||
`${absolutePathForPhoto(photo)}/image`;
|
||||
|
||||
export const absolutePathForTagImage = (tag: string) =>
|
||||
`${absolutePathForTag(tag)}/image`;
|
||||
|
||||
// p/[photoId]
|
||||
export const isPathPhoto = (pathname = '') =>
|
||||
/^\/p\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// p/[photoId]/share
|
||||
export const isPathPhotoShare = (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 = '') =>
|
||||
pathname.startsWith('/sign-in');
|
||||
|
||||
export const isPathAdmin = (pathname = '') =>
|
||||
pathname.startsWith('/admin');
|
||||
|
||||
export const isPathProtected = (pathname = '') =>
|
||||
pathname.startsWith(PREFIX_ADMIN) ||
|
||||
pathname.startsWith(PATH_ADMIN) ||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user