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",
|
"thephotoblog",
|
||||||
"trpc",
|
"trpc",
|
||||||
"unnest",
|
"unnest",
|
||||||
|
"UsKSGcbt",
|
||||||
"WRHGZC",
|
"WRHGZC",
|
||||||
"zadd",
|
"zadd",
|
||||||
"zrange"
|
"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",
|
"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
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 { 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>}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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,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>}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user