commit
5f5d5b2116
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -14,6 +14,7 @@
|
||||
"thephotoblog",
|
||||
"trpc",
|
||||
"unnest",
|
||||
"UsKSGcbt",
|
||||
"WRHGZC",
|
||||
"zadd",
|
||||
"zrange"
|
||||
|
||||
122
__tests__/path.test.ts
Normal file
122
__tests__/path.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
getEscapePath,
|
||||
getPathComponents,
|
||||
isPathCamera,
|
||||
isPathCameraPhoto,
|
||||
isPathCameraPhotoShare,
|
||||
isPathCameraShare,
|
||||
isPathPhoto,
|
||||
isPathPhotoShare,
|
||||
isPathTag,
|
||||
isPathTagPhoto,
|
||||
isPathTagPhotoShare,
|
||||
isPathTagShare,
|
||||
} from '@/site/paths';
|
||||
import { getMakeModelFromCameraString } from '@/camera';
|
||||
|
||||
const PHOTO_ID = 'UsKSGcbt';
|
||||
const TAG = 'tag-name';
|
||||
const CAMERA = 'fujifilm-x-t1';
|
||||
const CAMERA_OBJECT = getMakeModelFromCameraString(CAMERA);
|
||||
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}/${SHARE}`;
|
||||
|
||||
const PATH_CAMERA = `/shot-on/${CAMERA}`;
|
||||
const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`;
|
||||
const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`;
|
||||
const PATH_CAMERA_PHOTO_SHARE = `${PATH_CAMERA_PHOTO}/${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);
|
||||
expect(isPathCamera(PATH_CAMERA)).toBe(true);
|
||||
expect(isPathCameraShare(PATH_CAMERA_SHARE)).toBe(true);
|
||||
expect(isPathCameraPhoto(PATH_CAMERA_PHOTO)).toBe(true);
|
||||
expect(isPathCameraPhotoShare(PATH_CAMERA_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);
|
||||
expect(isPathCamera(PATH_TAG_SHARE)).toBe(false);
|
||||
expect(isPathCameraShare(PATH_TAG)).toBe(false);
|
||||
expect(isPathCameraPhoto(PATH_PHOTO_SHARE)).toBe(false);
|
||||
expect(isPathCameraPhotoShare(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,
|
||||
});
|
||||
expect(getPathComponents(PATH_CAMERA)).toEqual({
|
||||
camera: CAMERA_OBJECT,
|
||||
});
|
||||
expect(getPathComponents(PATH_CAMERA_SHARE)).toEqual({
|
||||
camera: CAMERA_OBJECT,
|
||||
});
|
||||
expect(getPathComponents(PATH_CAMERA_PHOTO)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
camera: CAMERA_OBJECT,
|
||||
});
|
||||
expect(getPathComponents(PATH_CAMERA_PHOTO_SHARE)).toEqual({
|
||||
photoId: PHOTO_ID,
|
||||
camera: CAMERA_OBJECT,
|
||||
});
|
||||
});
|
||||
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);
|
||||
// Camera views
|
||||
expect(getEscapePath(PATH_CAMERA)).toEqual(PATH_GRID);
|
||||
expect(getEscapePath(PATH_CAMERA_SHARE)).toEqual(PATH_CAMERA);
|
||||
expect(getEscapePath(PATH_CAMERA_PHOTO)).toEqual(PATH_CAMERA);
|
||||
expect(getEscapePath(PATH_CAMERA_PHOTO_SHARE)).toEqual(PATH_CAMERA_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);
|
||||
30
package.json
30
package.json
@ -5,30 +5,36 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest --watch",
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^13.5.3",
|
||||
"@next/bundle-analyzer": "^13.5.4",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@types/node": "^20.7.0",
|
||||
"@types/react": "18.2.22",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||
"@typescript-eslint/parser": "^6.7.3",
|
||||
"@testing-library/jest-dom": "^6.1.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/react": "18.2.24",
|
||||
"@types/react-dom": "18.2.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vercel/analytics": "^1.0.2",
|
||||
"@vercel/blob": "^0.12.5",
|
||||
"@vercel/blob": "^0.13.0",
|
||||
"@vercel/postgres": "0.5.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"camelcase-keys": "^9.0.0",
|
||||
"camelcase-keys": "^9.1.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"eslint": "8.50.0",
|
||||
"eslint-config-next": "13.5.3",
|
||||
"eslint-config-next": "13.5.4",
|
||||
"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",
|
||||
"next": "^13.5.4",
|
||||
"next-auth": "0.0.0-manual.c885ac1d",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "8.4.30",
|
||||
"postcss": "8.4.31",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-icons": "^4.11.0",
|
||||
|
||||
3140
pnpm-lock.yaml
generated
3140
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -9,11 +9,16 @@ import SiteGrid from '@/components/SiteGrid';
|
||||
import {
|
||||
deletePhotoAction,
|
||||
deleteBlobPhotoAction,
|
||||
syncCacheAction,
|
||||
} from '@/photo/actions';
|
||||
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 {
|
||||
@ -23,6 +28,7 @@ import {
|
||||
getPhotosCountIncludingHiddenCached,
|
||||
} from '@/cache';
|
||||
import { AiOutlineEyeInvisible } from 'react-icons/ai';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -54,7 +60,21 @@ export default async function AdminPage({
|
||||
contentMain={
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-8">
|
||||
<PhotoUploadInput />
|
||||
<div className="flex items-center">
|
||||
<div className="flex-grow">
|
||||
<PhotoUploadInput />
|
||||
</div>
|
||||
<form
|
||||
className="hidden md:block"
|
||||
action={syncCacheAction}
|
||||
>
|
||||
<SubmitButtonWithStatus
|
||||
icon={<BiTrash />}
|
||||
>
|
||||
Clear Cache
|
||||
</SubmitButtonWithStatus>
|
||||
</form>
|
||||
</div>
|
||||
{blobUploadUrls.length > 0 &&
|
||||
<BlobUrls
|
||||
blobUrls={blobUploadUrls}
|
||||
@ -123,7 +143,7 @@ export default async function AdminPage({
|
||||
</Fragment>)}
|
||||
</AdminGrid>
|
||||
{showMorePhotos &&
|
||||
<MorePhotos path={`/admin/photos?next=${offset + 1}`} />}
|
||||
<MorePhotos path={pathForAdminPhotos(offset + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosCountCached,
|
||||
getUniqueCamerasCached,
|
||||
getUniqueTagsCached,
|
||||
} from '@/cache';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import HeaderList from '@/components/HeaderList';
|
||||
import MorePhotos from '@/components/MorePhotos';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
|
||||
import PhotoCamera from '@/camera/PhotoCamera';
|
||||
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';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@ -31,10 +36,12 @@ export default async function GridPage({
|
||||
photos,
|
||||
count,
|
||||
tags,
|
||||
cameras,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit }),
|
||||
getPhotosCountCached(),
|
||||
getUniqueTagsCached(),
|
||||
getUniqueCamerasCached(),
|
||||
]);
|
||||
|
||||
const showMorePhotos = count > photos.length;
|
||||
@ -45,13 +52,31 @@ 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
|
||||
items={tags.map(tag => <PhotoTag key={tag} tag={tag} />)}
|
||||
staggerOnFirstLoadOnly
|
||||
contentSide={<div className="sticky top-4 space-y-4">
|
||||
{tags.length > 0 && <HeaderList
|
||||
title='Tags'
|
||||
icon={<FaTag size={12} />}
|
||||
items={tags.map(tag =>
|
||||
<PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
showIcon={false}
|
||||
/>)}
|
||||
/>}
|
||||
{cameras.length > 0 && <HeaderList
|
||||
title="Cameras"
|
||||
icon={<IoMdCamera size={13} />}
|
||||
items={cameras.map(({ cameraKey, camera }) =>
|
||||
<PhotoCamera
|
||||
key={cameraKey}
|
||||
camera={camera}
|
||||
showIcon={false}
|
||||
hideApple
|
||||
/>)}
|
||||
/>}
|
||||
</div>}
|
||||
sideHiddenOnMobile
|
||||
/>
|
||||
: <PhotosEmptyState />
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
MAX_PHOTOS_TO_SHOW_HOME,
|
||||
} from '@/photo/image-response';
|
||||
import HomeImageResponse from '@/photo/image-response/HomeImageResponse';
|
||||
import { getIBMPlexMonoMedium } from '@/site/font';
|
||||
import { ImageResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -13,15 +14,17 @@ export async function GET() {
|
||||
const [
|
||||
photos,
|
||||
headers,
|
||||
{ fontFamily, fonts },
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: MAX_PHOTOS_TO_SHOW_HOME }),
|
||||
getImageCacheHeadersForAuth(await auth()),
|
||||
getIBMPlexMonoMedium(),
|
||||
]);
|
||||
|
||||
const { width, height } = IMAGE_OG_SMALL_SIZE;
|
||||
|
||||
return new ImageResponse(
|
||||
<HomeImageResponse {...{ photos, width, height }}/>,
|
||||
{ width, height, headers },
|
||||
<HomeImageResponse {...{ photos, width, height, fontFamily }}/>,
|
||||
{ width, height, headers, fonts },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,10 @@ import { ImageResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(_request: Request, context: any){
|
||||
export async function GET(
|
||||
_: Request,
|
||||
context: { params: { photoId: string } },
|
||||
) {
|
||||
const [
|
||||
photo,
|
||||
{ fontFamily, fonts },
|
||||
|
||||
@ -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 />
|
||||
|
||||
91
src/app/(static)/shot-on/[camera]/[photoId]/layout.tsx
Normal file
91
src/app/(static)/shot-on/[camera]/[photoId]/layout.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import {
|
||||
descriptionForPhoto,
|
||||
titleForPhoto,
|
||||
} from '@/photo';
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
PATH_ROOT,
|
||||
absolutePathForPhoto,
|
||||
absolutePathForPhotoImage,
|
||||
} from '@/site/paths';
|
||||
import PhotoDetailPage from '@/photo/PhotoDetailPage';
|
||||
import { getPhotoCached, getPhotosCached } from '@/cache';
|
||||
import { getPhotos, getUniqueCameras } from '@/services/postgres';
|
||||
import { cameraFromPhoto } from '@/camera';
|
||||
|
||||
interface PhotoCameraProps {
|
||||
params: { photoId: string, camera: string }
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const params: PhotoCameraProps[] = [];
|
||||
|
||||
const cameras = await getUniqueCameras();
|
||||
cameras.forEach(async ({ cameraKey, camera }) => {
|
||||
const photos = await getPhotos({ camera });
|
||||
params.push(...photos.map(photo => ({
|
||||
params: { photoId: photo.id, camera: cameraKey },
|
||||
})));
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { photoId, camera },
|
||||
}: PhotoCameraProps): Promise<Metadata> {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { return {}; }
|
||||
|
||||
const title = titleForPhoto(photo);
|
||||
const description = descriptionForPhoto(photo);
|
||||
const images = absolutePathForPhotoImage(photo);
|
||||
const url = absolutePathForPhoto(
|
||||
photo,
|
||||
undefined,
|
||||
cameraFromPhoto(photo, camera),
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
images,
|
||||
description,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PhotoCameraPage({
|
||||
params: { photoId, camera: cameraProp },
|
||||
children,
|
||||
}: PhotoCameraProps & {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { redirect(PATH_ROOT); }
|
||||
|
||||
const camera = cameraFromPhoto(photo, cameraProp);
|
||||
|
||||
const photos = await getPhotosCached({ camera });
|
||||
|
||||
return <>
|
||||
{children}
|
||||
<PhotoDetailPage
|
||||
photo={photo}
|
||||
photos={photos}
|
||||
camera={camera}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
3
src/app/(static)/shot-on/[camera]/[photoId]/page.tsx
Normal file
3
src/app/(static)/shot-on/[camera]/[photoId]/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
36
src/app/(static)/shot-on/[camera]/[photoId]/share/page.tsx
Normal file
36
src/app/(static)/shot-on/[camera]/[photoId]/share/page.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { getPhotoCached } from '@/cache';
|
||||
import { cameraFromPhoto } from '@/camera';
|
||||
import PhotoShareModal from '@/photo/PhotoShareModal';
|
||||
import { getPhotos, getUniqueCameras } from '@/services/postgres';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface PhotoCameraParams {
|
||||
params: { photoId: string, camera: string }
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const params: PhotoCameraParams[] = [];
|
||||
|
||||
const cameras = await getUniqueCameras();
|
||||
cameras.forEach(async ({ cameraKey, camera }) => {
|
||||
const photos = await getPhotos({ camera });
|
||||
params.push(...photos.map(photo => ({
|
||||
params: { photoId: photo.id, camera: cameraKey },
|
||||
})));
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export default async function Share({
|
||||
params: { photoId, camera: cameraProp },
|
||||
}: PhotoCameraParams) {
|
||||
const photo = await getPhotoCached(photoId);
|
||||
|
||||
if (!photo) { return redirect(PATH_ROOT); }
|
||||
|
||||
const camera = cameraFromPhoto(photo, cameraProp);
|
||||
|
||||
return <PhotoShareModal {...{ photo, camera }} />;
|
||||
}
|
||||
45
src/app/(static)/shot-on/[camera]/image/route.tsx
Normal file
45
src/app/(static)/shot-on/[camera]/image/route.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { auth } from '@/auth';
|
||||
import { getImageCacheHeadersForAuth, getPhotosCached } from '@/cache';
|
||||
import { getMakeModelFromCameraString } from '@/camera';
|
||||
import {
|
||||
IMAGE_OG_SMALL_SIZE,
|
||||
MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
} from '@/photo/image-response';
|
||||
import CameraImageResponse from '@/photo/image-response/CameraImageResponse';
|
||||
import { getIBMPlexMonoMedium } from '@/site/font';
|
||||
import { ImageResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(
|
||||
_: Request,
|
||||
context: { params: { camera: string } },
|
||||
) {
|
||||
const camera = getMakeModelFromCameraString(context.params.camera);
|
||||
|
||||
const [
|
||||
photos,
|
||||
{ fontFamily, fonts },
|
||||
headers,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_TAG,
|
||||
camera: camera,
|
||||
}),
|
||||
getIBMPlexMonoMedium(),
|
||||
getImageCacheHeadersForAuth(await auth()),
|
||||
]);
|
||||
|
||||
const { width, height } = IMAGE_OG_SMALL_SIZE;
|
||||
|
||||
return new ImageResponse(
|
||||
<CameraImageResponse {...{
|
||||
camera,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}}/>,
|
||||
{ width, height, fonts, headers },
|
||||
);
|
||||
}
|
||||
65
src/app/(static)/shot-on/[camera]/page.tsx
Normal file
65
src/app/(static)/shot-on/[camera]/page.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { getPhotosCached } from '@/cache';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
import { getMakeModelFromCameraString } from '@/camera';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
import { getUniqueCameras } from '@/services/postgres';
|
||||
import { Metadata } from 'next';
|
||||
import { generateMetaForCamera } from '@/camera/meta';
|
||||
|
||||
interface CameraProps {
|
||||
params: { camera: string }
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const cameras = await getUniqueCameras();
|
||||
return cameras.map(({ cameraKey }): CameraProps => ({
|
||||
params: { camera: cameraKey },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: CameraProps): Promise<Metadata> {
|
||||
const camera = getMakeModelFromCameraString(params.camera);
|
||||
const photos = await getPhotosCached({ camera });
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
} = generateMetaForCamera(camera, photos);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
images,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CameraPage({ params }:CameraProps) {
|
||||
const camera = getMakeModelFromCameraString(params.camera);
|
||||
|
||||
const photos = await getPhotosCached({ camera });
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
key="Camera Grid"
|
||||
contentMain={<div className="space-y-8 mt-4">
|
||||
<CameraHeader camera={camera} photos={photos} />
|
||||
<PhotoGrid photos={photos} camera={camera} />
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
src/app/(static)/shot-on/[camera]/share/page.tsx
Normal file
70
src/app/(static)/shot-on/[camera]/share/page.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { getPhotosCached } from '@/cache';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import { cameraFromPhoto, getMakeModelFromCameraString } from '@/camera';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
import CameraShareModal from '@/camera/CameraShareModal';
|
||||
import { generateMetaForCamera } from '@/camera/meta';
|
||||
import PhotoGrid from '@/photo/PhotoGrid';
|
||||
import { getUniqueCameras } from '@/services/postgres';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface CameraProps {
|
||||
params: { camera: string }
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const camera = await getUniqueCameras();
|
||||
return camera.map(({ cameraKey }): CameraProps => ({
|
||||
params: { camera: cameraKey },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: CameraProps): Promise<Metadata> {
|
||||
const camera = getMakeModelFromCameraString(params.camera);
|
||||
|
||||
const photos = await getPhotosCached({ camera });
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
} = generateMetaForCamera(camera, photos);
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images,
|
||||
url,
|
||||
},
|
||||
twitter: {
|
||||
images,
|
||||
description,
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Share({ params }: CameraProps) {
|
||||
const cameraFromParams = getMakeModelFromCameraString(params.camera);
|
||||
|
||||
const photos = await getPhotosCached({ camera: cameraFromParams });
|
||||
|
||||
const camera = cameraFromPhoto(photos[0], cameraFromParams);
|
||||
|
||||
return <>
|
||||
<CameraShareModal {...{ camera, photos }} />
|
||||
<SiteGrid
|
||||
key="Camera Grid"
|
||||
contentMain={<div className="space-y-8 mt-4">
|
||||
<CameraHeader camera={camera} photos={photos} />
|
||||
<PhotoGrid photos={photos} camera={camera} />
|
||||
</div>}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -10,8 +10,11 @@ import { ImageResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(_request: Request, context: any) {
|
||||
const tag = context.params.tag as string;
|
||||
export async function GET(
|
||||
_: Request,
|
||||
context: { params: { tag: string } },
|
||||
) {
|
||||
const tag = context.params.tag;
|
||||
|
||||
const [
|
||||
photos,
|
||||
|
||||
@ -54,7 +54,7 @@ export default async function Share({
|
||||
}) {
|
||||
const photos = await getPhotosCached({ tag });
|
||||
return <>
|
||||
<TagShareModal tag={tag} photos={photos} />
|
||||
<TagShareModal {...{ tag, photos }} />
|
||||
<SiteGrid
|
||||
key="Tag Grid"
|
||||
contentMain={<div className="space-y-8 mt-4">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -13,7 +13,6 @@ declare module 'next-auth' {
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
CSRF_experimental,
|
||||
} = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
|
||||
82
src/cache/index.ts
vendored
82
src/cache/index.ts
vendored
@ -1,4 +1,3 @@
|
||||
import type { Session } from 'next-auth/types';
|
||||
import { revalidateTag, unstable_cache } from 'next/cache';
|
||||
import {
|
||||
GetPhotosOptions,
|
||||
@ -6,39 +5,55 @@ import {
|
||||
getPhotos,
|
||||
getPhotosCount,
|
||||
getPhotosCountIncludingHidden,
|
||||
getUniqueCameras,
|
||||
getUniqueTags,
|
||||
} from '@/services/postgres';
|
||||
import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo';
|
||||
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
|
||||
import { AuthSession } from 'next-auth';
|
||||
|
||||
const TAG_PHOTOS = 'photos';
|
||||
const TAG_PHOTOS_COUNT = 'photos-count';
|
||||
const TAG_TAGS = 'tags';
|
||||
const TAG_CAMERAS = 'cameras';
|
||||
const TAG_BLOB = 'blob';
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
const getPhotosCacheTagForKey = (
|
||||
options: GetPhotosOptions,
|
||||
key: keyof GetPhotosOptions,
|
||||
): string | null => {
|
||||
switch (key) {
|
||||
// Primitive keys
|
||||
case 'sortBy':
|
||||
case 'limit':
|
||||
case 'offset':
|
||||
case 'tag':
|
||||
case 'includeHidden': {
|
||||
const value = options[key];
|
||||
return value ? `${key}-${value}` : null;
|
||||
}
|
||||
// Date keys
|
||||
case 'takenBefore':
|
||||
case 'takenAfterInclusive': {
|
||||
const value = options[key];
|
||||
return value ? `${key}-${value.toISOString()}` : null;
|
||||
}
|
||||
// Complex keys
|
||||
case 'camera': {
|
||||
const value = options[key];
|
||||
return value ? `${key}-${value.make}-${value.model}` : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPhotosCacheTags = (options: GetPhotosOptions = {}) => {
|
||||
const tags = [];
|
||||
const tags: string[] = [];
|
||||
|
||||
const {
|
||||
sortBy,
|
||||
limit,
|
||||
offset,
|
||||
tag,
|
||||
takenAfterInclusive,
|
||||
takenBefore,
|
||||
includeHidden,
|
||||
} = options;
|
||||
|
||||
if (sortBy !== undefined) { tags.push(`sortBy-${sortBy}`); }
|
||||
if (limit !== undefined) { tags.push(`limit-${limit}`); }
|
||||
if (offset !== undefined) { tags.push(`offset-${offset}`); }
|
||||
if (tag !== undefined) { tags.push(`tag-${tag}`); }
|
||||
// eslint-disable-next-line max-len
|
||||
if (takenBefore !== undefined) { tags.push(`takenBefore-${takenBefore.toISOString()}`); }
|
||||
// eslint-disable-next-line max-len
|
||||
if (takenAfterInclusive !== undefined) { tags.push(`takenAfterInclusive-${takenAfterInclusive.toISOString()}`); }
|
||||
// eslint-disable-next-line max-len
|
||||
if (includeHidden !== undefined) { tags.push(`includeHidden-${includeHidden}`); }
|
||||
Object.keys(options).forEach(key => {
|
||||
const tag = getPhotosCacheTagForKey(options, key as keyof GetPhotosOptions);
|
||||
if (tag) { tags.push(tag); }
|
||||
});
|
||||
|
||||
return tags;
|
||||
};
|
||||
@ -48,6 +63,12 @@ const getPhotoCacheTag = (photoId: string) => `photo-${photoId}`;
|
||||
export const revalidatePhotosTag = () =>
|
||||
revalidateTag(TAG_PHOTOS);
|
||||
|
||||
export const revalidateTagsTag = () =>
|
||||
revalidateTag(TAG_TAGS);
|
||||
|
||||
export const revalidateCamerasTag = () =>
|
||||
revalidateTag(TAG_CAMERAS);
|
||||
|
||||
export const revalidateBlobTag = () =>
|
||||
revalidateTag(TAG_BLOB);
|
||||
|
||||
@ -56,6 +77,13 @@ export const revalidatePhotosAndBlobTag = () => {
|
||||
revalidateTag(TAG_BLOB);
|
||||
};
|
||||
|
||||
export const revalidateAllTags = () => {
|
||||
revalidatePhotosTag();
|
||||
revalidateTagsTag();
|
||||
revalidateCamerasTag();
|
||||
revalidateBlobTag();
|
||||
};
|
||||
|
||||
export const getPhotosCached: typeof getPhotos = (...args) =>
|
||||
unstable_cache(
|
||||
() => getPhotos(...args),
|
||||
@ -97,6 +125,14 @@ export const getUniqueTagsCached: typeof getUniqueTags = (...args) =>
|
||||
}
|
||||
)();
|
||||
|
||||
export const getUniqueCamerasCached: typeof getUniqueCameras = (...args) =>
|
||||
unstable_cache(
|
||||
() => getUniqueCameras(...args),
|
||||
[TAG_PHOTOS, TAG_CAMERAS], {
|
||||
tags: [TAG_PHOTOS, TAG_CAMERAS],
|
||||
}
|
||||
)();
|
||||
|
||||
export const getBlobUploadUrlsCached: typeof getBlobUploadUrls = (...args) =>
|
||||
unstable_cache(
|
||||
() => getBlobUploadUrls(...args),
|
||||
@ -113,7 +149,7 @@ export const getBlobPhotoUrlsCached: typeof getBlobPhotoUrls = (...args) =>
|
||||
}
|
||||
)();
|
||||
|
||||
export const getImageCacheHeadersForAuth = (session?: Session) => {
|
||||
export const getImageCacheHeadersForAuth = (session: AuthSession | null) => {
|
||||
return {
|
||||
'Cache-Control': !session?.user
|
||||
? 's-maxage=3600, stale-while-revalidate=59'
|
||||
|
||||
28
src/camera/CameraHeader.tsx
Normal file
28
src/camera/CameraHeader.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { pathForCameraShare } from '@/site/paths';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { Camera, cameraFromPhoto } from '.';
|
||||
import PhotoCamera from './PhotoCamera';
|
||||
import { descriptionForCameraPhotos } from './meta';
|
||||
|
||||
export default function CameraHeader({
|
||||
camera: cameraProp,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
}: {
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
}) {
|
||||
const camera = cameraFromPhoto(photos[0], cameraProp);
|
||||
return (
|
||||
<PhotoHeader
|
||||
entity={<PhotoCamera {...{ camera }} />}
|
||||
entityVerb="Photo"
|
||||
entityDescription={descriptionForCameraPhotos(photos)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
sharePath={pathForCameraShare(camera)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
src/camera/CameraOGTile.tsx
Normal file
39
src/camera/CameraOGTile.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { absolutePathForCameraImage, pathForCamera } from '@/site/paths';
|
||||
import OGTile from '@/components/OGTile';
|
||||
import { Camera } from '.';
|
||||
import { descriptionForCameraPhotos, titleForCamera } from './meta';
|
||||
|
||||
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
|
||||
|
||||
export default function CameraOGTile({
|
||||
camera,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
}: {
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
}) {
|
||||
return (
|
||||
<OGTile {...{
|
||||
title: titleForCamera(camera, photos),
|
||||
description: descriptionForCameraPhotos(photos, true),
|
||||
path: pathForCamera(camera),
|
||||
pathImageAbsolute: absolutePathForCameraImage(camera),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
23
src/camera/CameraShareModal.tsx
Normal file
23
src/camera/CameraShareModal.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { absolutePathForCamera, pathForCamera } from '@/site/paths';
|
||||
import { Photo } from '../photo';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import CameraOGTile from './CameraOGTile';
|
||||
import { Camera } from '.';
|
||||
|
||||
export default function CameraShareModal({
|
||||
camera,
|
||||
photos,
|
||||
}: {
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photos"
|
||||
pathShare={absolutePathForCamera(camera)}
|
||||
pathClose={pathForCamera(camera)}
|
||||
>
|
||||
<CameraOGTile {...{ camera, photos }} />
|
||||
</ShareModal>
|
||||
);
|
||||
};
|
||||
44
src/camera/PhotoCamera.tsx
Normal file
44
src/camera/PhotoCamera.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { AiFillApple } from 'react-icons/ai';
|
||||
import { cc } from '@/utility/css';
|
||||
import Link from 'next/link';
|
||||
import { pathForCamera } from '@/site/paths';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { Camera } from '.';
|
||||
|
||||
export default function PhotoCamera({
|
||||
camera,
|
||||
showIcon = true,
|
||||
hideApple = true,
|
||||
}: {
|
||||
camera: Camera
|
||||
showIcon?: boolean
|
||||
hideApple?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={pathForCamera(camera)}
|
||||
className={cc(
|
||||
'inline-flex items-center self-start',
|
||||
'uppercase',
|
||||
'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
)}
|
||||
>
|
||||
{showIcon && <>
|
||||
<IoMdCamera size={13} />
|
||||
|
||||
</>}
|
||||
{!(hideApple && camera.make?.toLowerCase() === 'apple') &&
|
||||
<>
|
||||
{camera.make?.toLowerCase() === 'apple'
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
className="translate-y-[-0.5px]"
|
||||
size={14}
|
||||
/>
|
||||
: camera.make}
|
||||
|
||||
</>}
|
||||
{camera.model}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
31
src/camera/index.ts
Normal file
31
src/camera/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { parameterize } from '@/utility/string';
|
||||
|
||||
const CAMERA_PLACEHOLDER: Camera = { make: 'Camera', model: 'Model' };
|
||||
|
||||
export type Camera = {
|
||||
make: string
|
||||
model: string
|
||||
};
|
||||
|
||||
export const createCameraKey = (make: string, model: string) =>
|
||||
parameterize(`${make}-${model}`);
|
||||
|
||||
// Assumes no makes ('Fujifilm,' 'Apple,' 'Canon', etc.) have dashes
|
||||
export const getMakeModelFromCameraString = (camera: string): Camera => {
|
||||
const [make, model] = camera.toLowerCase().split(/[-| ](.*)/s);
|
||||
return { make, model };
|
||||
};
|
||||
|
||||
export const cameraFromPhoto = (
|
||||
photo: Photo | undefined,
|
||||
fallback?: Camera | string,
|
||||
): Camera =>
|
||||
photo?.make && photo?.model
|
||||
? { make: photo.make, model: photo.model }
|
||||
: typeof fallback === 'string'
|
||||
? getMakeModelFromCameraString(fallback)
|
||||
: fallback ?? CAMERA_PLACEHOLDER;
|
||||
|
||||
export const formatCameraText = ({ make, model }: Camera) =>
|
||||
`${make} ${model}`;
|
||||
35
src/camera/meta.ts
Normal file
35
src/camera/meta.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Photo, descriptionForPhotoSet, photoQuantityText } from '@/photo';
|
||||
import { Camera, cameraFromPhoto, formatCameraText } from '.';
|
||||
import {
|
||||
absolutePathForCamera,
|
||||
absolutePathForCameraImage,
|
||||
} from '@/site/paths';
|
||||
|
||||
// Meta functions moved to separate file to avoid
|
||||
// dependencies (camelcase-keys) found in photo/index.ts
|
||||
// which cause Jest to crash
|
||||
|
||||
export const titleForCamera = (
|
||||
camera: Camera,
|
||||
photos: Photo[],
|
||||
) => [
|
||||
'Shot on',
|
||||
formatCameraText(cameraFromPhoto(photos[0], camera)),
|
||||
photoQuantityText(photos),
|
||||
].join(' ');
|
||||
|
||||
export const descriptionForCameraPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
) =>
|
||||
descriptionForPhotoSet(photos, undefined, dateBased);
|
||||
|
||||
export const generateMetaForCamera = (
|
||||
camera: Camera,
|
||||
photos: Photo[]
|
||||
) => ({
|
||||
url: absolutePathForCamera(camera),
|
||||
title: titleForCamera(camera, photos),
|
||||
description: descriptionForCameraPhotos(photos, true),
|
||||
images: absolutePathForCameraImage(camera),
|
||||
});
|
||||
@ -16,6 +16,7 @@ export interface AnimationConfig {
|
||||
|
||||
interface Props extends AnimationConfig {
|
||||
className?: string
|
||||
classNameItem?: string
|
||||
items: JSX.Element[]
|
||||
animateFromAppState?: boolean
|
||||
animateOnFirstLoadOnly?: boolean
|
||||
@ -24,6 +25,7 @@ interface Props extends AnimationConfig {
|
||||
|
||||
function AnimateItems({
|
||||
className,
|
||||
classNameItem,
|
||||
items,
|
||||
type = 'scale',
|
||||
duration = 0.6,
|
||||
@ -60,16 +62,15 @@ function AnimateItems({
|
||||
switch (typeResolved) {
|
||||
case 'left': return {
|
||||
opacity: 0,
|
||||
translateX: distanceOffset,
|
||||
transform: `translateX(${distanceOffset}px)`,
|
||||
};
|
||||
case 'right': return {
|
||||
opacity: 0,
|
||||
translateX: -distanceOffset,
|
||||
transform: `translateX(${-distanceOffset}px)`,
|
||||
};
|
||||
default: return {
|
||||
opacity: 0,
|
||||
scale: scaleOffset,
|
||||
translateY: distanceOffset,
|
||||
transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -96,14 +97,13 @@ function AnimateItems({
|
||||
{items.map((item, index) =>
|
||||
<motion.div
|
||||
key={index}
|
||||
style={getInitialVariant()}
|
||||
className={classNameItem}
|
||||
// style={getInitialVariant()}
|
||||
variants={{
|
||||
hidden: getInitialVariant(),
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
transform: 'translateX(0) translateY(0) scale(1)',
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const RETRY_DELAY = 2000;
|
||||
|
||||
export default function CanvasBlurCapture({
|
||||
imageUrl,
|
||||
onCapture,
|
||||
@ -21,23 +23,20 @@ export default function CanvasBlurCapture({
|
||||
scale?: number
|
||||
quality?: number
|
||||
}) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const refCanvas = useRef<HTMLCanvasElement>(null);
|
||||
const refHasCompleted = useRef(false);
|
||||
const refTimeouts = useRef<NodeJS.Timeout[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.src = imageUrl;
|
||||
image.onload = () => {
|
||||
timeout = setTimeout(() => {
|
||||
const canvas = ref.current;
|
||||
const capture = () => {
|
||||
if (!refHasCompleted.current) {
|
||||
const canvas = refCanvas.current;
|
||||
if (canvas) {
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const context = ref.current?.getContext('2d');
|
||||
const context = refCanvas.current?.getContext('2d');
|
||||
if (context) {
|
||||
context.scale(scale, scale);
|
||||
context.filter =
|
||||
@ -48,18 +47,35 @@ export default function CanvasBlurCapture({
|
||||
-edgeCompensation,
|
||||
-edgeCompensation,
|
||||
width + edgeCompensation * 2,
|
||||
width * image.height / image.width
|
||||
+ edgeCompensation * 2,
|
||||
width * image.height / image.width + edgeCompensation * 2,
|
||||
);
|
||||
refHasCompleted.current = true;
|
||||
onCapture(canvas.toDataURL('image/jpeg', quality));
|
||||
} else {
|
||||
console.error('Cannot get 2d context');
|
||||
// Retry capture in case canvas is not available
|
||||
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
|
||||
}
|
||||
} else {
|
||||
console.error('Cannot generate blur data: canvas not found');
|
||||
// Retry capture in case canvas is not available
|
||||
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.src = imageUrl;
|
||||
image.onload = capture;
|
||||
|
||||
// Attempt delayed capture in case image.onload never fires
|
||||
refTimeouts.current.push(setTimeout(capture, RETRY_DELAY));
|
||||
|
||||
// Store timeout ref to ensure it's closed over
|
||||
// in cleanup function (recommended by exhaustive-deps)
|
||||
const timeouts = refTimeouts.current;
|
||||
return () => timeouts.forEach(clearTimeout);
|
||||
}, [
|
||||
imageUrl,
|
||||
onCapture,
|
||||
@ -71,6 +87,6 @@ export default function CanvasBlurCapture({
|
||||
]);
|
||||
|
||||
return (
|
||||
<canvas ref={ref} className={hidden ? 'hidden' : undefined} />
|
||||
<canvas ref={refCanvas} className={hidden ? 'hidden' : undefined} />
|
||||
);
|
||||
}
|
||||
|
||||
30
src/components/HeaderList.tsx
Normal file
30
src/components/HeaderList.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { cc } from '@/utility/css';
|
||||
import AnimateItems from './AnimateItems';
|
||||
|
||||
export default function HeaderList({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
}: {
|
||||
title: string,
|
||||
icon?: JSX.Element,
|
||||
items: JSX.Element[]
|
||||
}) {
|
||||
return (
|
||||
<AnimateItems
|
||||
items={[
|
||||
<div key="header" className={cc(
|
||||
'text-gray-900',
|
||||
'dark:text-gray-100',
|
||||
'flex items-center mb-0.5',
|
||||
'uppercase',
|
||||
)}>
|
||||
{icon}
|
||||
{icon && <> </>}
|
||||
{title}
|
||||
</div>,
|
||||
].concat(items)}
|
||||
classNameItem="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -11,12 +11,14 @@ export default function IconPathButton({
|
||||
prefetch,
|
||||
loaderDelay = 250,
|
||||
shouldScroll = true,
|
||||
shouldReplace,
|
||||
}: {
|
||||
icon: JSX.Element
|
||||
path: string
|
||||
prefetch?: boolean
|
||||
loaderDelay?: number
|
||||
shouldScroll?: boolean
|
||||
shouldReplace?: boolean
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -44,8 +46,13 @@ export default function IconPathButton({
|
||||
return (
|
||||
<IconButton
|
||||
icon={icon}
|
||||
onClick={() => startTransition(() =>
|
||||
router.push(path, { scroll: shouldScroll }))}
|
||||
onClick={() => startTransition(() => {
|
||||
if (shouldReplace) {
|
||||
router.replace(path, { scroll: shouldScroll });
|
||||
} else {
|
||||
router.push(path, { scroll: shouldScroll });
|
||||
}
|
||||
})}
|
||||
isLoading={shouldShowLoader}
|
||||
className={cc(
|
||||
'translate-y-[-0.5px]',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -20,6 +20,7 @@ export default function ShareButton({
|
||||
: undefined} />,
|
||||
prefetch,
|
||||
shouldScroll,
|
||||
shouldReplace: true,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,17 +6,21 @@ import PhotoGrid from './PhotoGrid';
|
||||
import { cc } from '@/utility/css';
|
||||
import PhotoLinks from './PhotoLinks';
|
||||
import TagHeader from '@/tag/TagHeader';
|
||||
import { Camera } from '@/camera';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
|
||||
export default function PhotoDetailPage({
|
||||
photo,
|
||||
photos,
|
||||
photosGrid,
|
||||
tag,
|
||||
camera,
|
||||
}: {
|
||||
photo: Photo
|
||||
photos: Photo[]
|
||||
photosGrid?: Photo[]
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@ -31,6 +35,17 @@ export default function PhotoDetailPage({
|
||||
selectedPhoto={photo}
|
||||
/>}
|
||||
/>}
|
||||
{camera &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={
|
||||
<CameraHeader
|
||||
key={tag}
|
||||
camera={camera}
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
/>}
|
||||
/>}
|
||||
<AnimateItems
|
||||
className="md:mb-8"
|
||||
animateFromAppState
|
||||
@ -41,7 +56,9 @@ export default function PhotoDetailPage({
|
||||
tag={tag}
|
||||
priority
|
||||
prefetchShare
|
||||
shareCamera={camera !== undefined}
|
||||
shouldScrollOnShare={false}
|
||||
showCamera={!camera}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
@ -58,7 +75,12 @@ export default function PhotoDetailPage({
|
||||
'md:flex md:gap-4',
|
||||
'user-select-none',
|
||||
)}>
|
||||
<PhotoLinks photo={photo} photos={photos} tag={tag} />
|
||||
<PhotoLinks {...{
|
||||
photo,
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
}} />
|
||||
</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,48 @@ 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;
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
export default function PhotoGrid({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
tag,
|
||||
offset = 0,
|
||||
camera,
|
||||
fast,
|
||||
animate = true,
|
||||
animateOnFirstLoadOnly,
|
||||
staggerOnFirstLoadOnly = true,
|
||||
showMore,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
tag?: string
|
||||
offset?: number
|
||||
camera?: Camera
|
||||
fast?: boolean
|
||||
animate?: boolean
|
||||
animateOnFirstLoadOnly?: boolean
|
||||
staggerOnFirstLoadOnly?: boolean
|
||||
showMore?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AnimateItems
|
||||
className={cc(
|
||||
'grid gap-1',
|
||||
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
'items-center',
|
||||
)}
|
||||
type={animate === false ? 'none' : undefined}
|
||||
duration={fast ? 0.3 : undefined}
|
||||
staggerDelay={0.075}
|
||||
distanceOffset={40}
|
||||
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
|
||||
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
|
||||
items={photos.map(photo =>
|
||||
<PhotoSmall
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
tag={tag}
|
||||
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>}
|
||||
</>
|
||||
<AnimateItems
|
||||
className={cc(
|
||||
'grid gap-1',
|
||||
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
'items-center',
|
||||
)}
|
||||
type={animate === false ? 'none' : undefined}
|
||||
duration={fast ? 0.3 : undefined}
|
||||
staggerDelay={0.075}
|
||||
distanceOffset={40}
|
||||
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
|
||||
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
|
||||
items={photos.map(photo =>
|
||||
<PhotoSmall
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
selected={photo.id === selectedPhoto?.id}
|
||||
/>)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
54
src/photo/PhotoHeader.tsx
Normal file
54
src/photo/PhotoHeader.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { cc } from '@/utility/css';
|
||||
import { Photo, dateRangeForPhotos } from '.';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
|
||||
export default function PhotoHeader({
|
||||
entity,
|
||||
entityVerb,
|
||||
entityDescription,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
sharePath,
|
||||
}: {
|
||||
entity: JSX.Element
|
||||
entityVerb: string
|
||||
entityDescription: string
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
sharePath: string
|
||||
}) {
|
||||
const { start, end } = dateRangeForPhotos(photos);
|
||||
|
||||
const selectedPhotoIndex = selectedPhoto
|
||||
? photos.findIndex(photo => photo.id === selectedPhoto.id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={cc(
|
||||
'flex flex-col gap-y-0.5',
|
||||
'xs:grid grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
)}>
|
||||
{entity}
|
||||
<span className={cc(
|
||||
'inline-flex gap-2 items-center self-start',
|
||||
'uppercase text-gray-400 dark:text-gray-500',
|
||||
'sm:col-span-2 md:col-span-1 lg:col-span-2',
|
||||
)}>
|
||||
{selectedPhotoIndex !== undefined
|
||||
? `${entityVerb} ${selectedPhotoIndex + 1} of ${photos.length}`
|
||||
: entityDescription}
|
||||
{selectedPhotoIndex === undefined &&
|
||||
<ShareButton path={sharePath} dim />}
|
||||
</span>
|
||||
<span className={cc(
|
||||
'hidden sm:inline-block',
|
||||
'text-right uppercase',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}>
|
||||
{start === end
|
||||
? start
|
||||
: <>{start}<br />– {end}</>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,8 @@ import Link from 'next/link';
|
||||
import { pathForPhoto, pathForPhotoShare } from '@/site/paths';
|
||||
import PhotoTags from '@/tag/PhotoTags';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import PhotoCamera from '../camera/PhotoCamera';
|
||||
import { Camera, cameraFromPhoto } from '@/camera';
|
||||
|
||||
export default function PhotoLarge({
|
||||
photo,
|
||||
@ -13,15 +15,22 @@ export default function PhotoLarge({
|
||||
priority,
|
||||
prefetchShare,
|
||||
shouldScrollOnShare,
|
||||
showCamera = true,
|
||||
shareCamera,
|
||||
}: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
priority?: boolean
|
||||
prefetchShare?: boolean
|
||||
shouldScrollOnShare?: boolean
|
||||
showCamera?: boolean
|
||||
shareCamera?: boolean
|
||||
}) {
|
||||
const tagsToShow = photo.tags.filter(t => t !== tag);
|
||||
|
||||
const camera = cameraFromPhoto(photo);
|
||||
|
||||
const renderMiniGrid = (children: JSX.Element) =>
|
||||
<div className={cc(
|
||||
'flex gap-y-4',
|
||||
@ -63,9 +72,12 @@ export default function PhotoLarge({
|
||||
{tagsToShow.length > 0 &&
|
||||
<PhotoTags tags={tagsToShow} />}
|
||||
</div>
|
||||
<div className="uppercase">
|
||||
{photo.make} {photo.model}
|
||||
</div>
|
||||
{showCamera &&
|
||||
<PhotoCamera
|
||||
camera={camera}
|
||||
showIcon={false}
|
||||
hideApple={false}
|
||||
/>}
|
||||
</>)}
|
||||
{renderMiniGrid(<>
|
||||
<ul className={cc(
|
||||
@ -100,7 +112,11 @@ export default function PhotoLarge({
|
||||
</div>
|
||||
<div className="-translate-x-0.5">
|
||||
<ShareButton
|
||||
path={pathForPhotoShare(photo, tag)}
|
||||
path={pathForPhotoShare(
|
||||
photo,
|
||||
tag,
|
||||
shareCamera ? camera : undefined,
|
||||
)}
|
||||
prefetch={prefetchShare}
|
||||
shouldScroll={shouldScrollOnShare}
|
||||
/>
|
||||
|
||||
@ -6,16 +6,19 @@ import Link from 'next/link';
|
||||
import { AnimationConfig } from '../components/AnimateItems';
|
||||
import { useAppState } from '@/state';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
export default function PhotoLink({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
prefetch,
|
||||
nextPhotoAnimation,
|
||||
children,
|
||||
}: {
|
||||
photo?: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
prefetch?: boolean
|
||||
nextPhotoAnimation?: AnimationConfig
|
||||
children: ReactNode
|
||||
@ -25,7 +28,7 @@ export default function PhotoLink({
|
||||
return (
|
||||
photo
|
||||
? <Link
|
||||
href={pathForPhoto(photo, tag)}
|
||||
href={pathForPhoto(photo, tag, camera)}
|
||||
prefetch={prefetch}
|
||||
onClick={() => {
|
||||
if (nextPhotoAnimation) {
|
||||
|
||||
@ -7,6 +7,9 @@ import { useRouter } from 'next/navigation';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { useAppState } from '@/state';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
const LISTENER_KEYUP = 'keyup';
|
||||
|
||||
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
|
||||
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
|
||||
@ -15,10 +18,12 @@ export default function PhotoLinks({
|
||||
photo,
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
}: {
|
||||
photo: Photo
|
||||
photos: Photo[]
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -34,24 +39,34 @@ export default function PhotoLinks({
|
||||
case 'J':
|
||||
if (previousPhoto) {
|
||||
setNextPhotoAnimation?.(ANIMATION_RIGHT);
|
||||
router.push(pathForPhoto(previousPhoto, tag), { scroll: false });
|
||||
router.push(
|
||||
pathForPhoto(previousPhoto, tag, camera),
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ARROWRIGHT':
|
||||
case 'L':
|
||||
if (nextPhoto) {
|
||||
setNextPhotoAnimation?.(ANIMATION_LEFT);
|
||||
router.push(pathForPhoto(nextPhoto, tag), { scroll: false });
|
||||
router.push(
|
||||
pathForPhoto(nextPhoto, tag, camera),
|
||||
{ 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,
|
||||
camera,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -59,6 +74,7 @@ export default function PhotoLinks({
|
||||
photo={previousPhoto}
|
||||
nextPhotoAnimation={ANIMATION_RIGHT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
prefetch
|
||||
>
|
||||
PREV
|
||||
@ -67,6 +83,7 @@ export default function PhotoLinks({
|
||||
photo={nextPhoto}
|
||||
nextPhotoAnimation={ANIMATION_LEFT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
prefetch
|
||||
>
|
||||
NEXT
|
||||
|
||||
@ -2,19 +2,22 @@ import PhotoOGTile from '@/photo/PhotoOGTile';
|
||||
import { absolutePathForPhoto, pathForPhoto } from '@/site/paths';
|
||||
import { Photo } from '.';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
export default function PhotoShareModal({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
}: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photo"
|
||||
pathShare={absolutePathForPhoto(photo, tag)}
|
||||
pathClose={pathForPhoto(photo, tag)}
|
||||
pathShare={absolutePathForPhoto(photo, tag, camera)}
|
||||
pathClose={pathForPhoto(photo, tag, camera)}
|
||||
>
|
||||
<PhotoOGTile photo={photo} />
|
||||
</ShareModal>
|
||||
|
||||
@ -3,19 +3,22 @@ import ImageSmall from '@/components/ImageSmall';
|
||||
import Link from 'next/link';
|
||||
import { cc } from '@/utility/css';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { Camera } from '@/camera';
|
||||
|
||||
export default function PhotoSmall({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
selected,
|
||||
}: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
selected?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={pathForPhoto(photo, tag)}
|
||||
href={pathForPhoto(photo, tag, camera)}
|
||||
className={cc(
|
||||
'active:brightness-75',
|
||||
selected && 'brightness-50',
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -12,8 +12,8 @@ import {
|
||||
deleteBlobPhoto,
|
||||
} from '@/services/blob';
|
||||
import {
|
||||
revalidateAllTags,
|
||||
revalidateBlobTag,
|
||||
revalidatePhotosAndBlobTag,
|
||||
revalidatePhotosTag,
|
||||
} from '@/cache';
|
||||
import { IS_PRO_MODE } from '@/site/config';
|
||||
@ -38,7 +38,7 @@ export async function createPhotoAction(formData: FormData) {
|
||||
|
||||
await sqlInsertPhoto(photo);
|
||||
|
||||
revalidatePhotosAndBlobTag();
|
||||
revalidateAllTags();
|
||||
|
||||
redirect('/admin/photos');
|
||||
}
|
||||
@ -67,3 +67,7 @@ export async function deleteBlobPhotoAction(formData: FormData) {
|
||||
|
||||
revalidateBlobTag();
|
||||
};
|
||||
|
||||
export async function syncCacheAction() {
|
||||
revalidateAllTags();
|
||||
}
|
||||
|
||||
44
src/photo/image-response/CameraImageResponse.tsx
Normal file
44
src/photo/image-response/CameraImageResponse.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Photo } from '..';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import { Camera, cameraFromPhoto } from '@/camera';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
|
||||
export default function CameraImageResponse({
|
||||
camera: cameraProp,
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}: {
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
width: number
|
||||
height: number
|
||||
fontFamily: string
|
||||
}) {
|
||||
const { make, model } = cameraFromPhoto(photos[0], cameraProp);
|
||||
return (
|
||||
<ImageContainer {...{
|
||||
width,
|
||||
height,
|
||||
...photos.length === 0 && { background: 'black' },
|
||||
}}>
|
||||
<ImagePhotoGrid
|
||||
{...{
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
<IoMdCamera size={height * .09} />
|
||||
<span style={{textTransform: 'uppercase'}}>
|
||||
{make.toLowerCase() !== 'apple' && make}
|
||||
{model}
|
||||
</span>
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import { SITE_DOMAIN_OR_TITLE } from '@/site/config';
|
||||
import { Photo } from '..';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
|
||||
@ -6,10 +8,12 @@ export default function HomeImageResponse({
|
||||
photos,
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
width: number
|
||||
height: number
|
||||
fontFamily: string
|
||||
}) {
|
||||
return (
|
||||
<ImageContainer {...{ width, height }} >
|
||||
@ -20,6 +24,9 @@ export default function HomeImageResponse({
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
{SITE_DOMAIN_OR_TITLE}
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function ImageCaption({
|
||||
height,
|
||||
children,
|
||||
fontFamily,
|
||||
subhead,
|
||||
children,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
fontFamily: string
|
||||
children: React.ReactNode
|
||||
subhead?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: height * .053,
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
paddingTop: height * .6,
|
||||
paddingBottom: height * .075,
|
||||
@ -29,7 +33,29 @@ export default function ImageCaption({
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}>
|
||||
{children}
|
||||
{subhead &&
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: height * .053,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{subhead}
|
||||
</div>}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: height * .053,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import {
|
||||
ABSOLUTE_PATH_FOR_HOME_IMAGE,
|
||||
absolutePathForPhotoImage,
|
||||
} from '@/site/paths';
|
||||
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths';
|
||||
import { formatDateFromPostgresString } from '@/utility/date';
|
||||
import {
|
||||
formatAperture,
|
||||
@ -146,8 +143,7 @@ export const getPhotosLimitForQuery = (
|
||||
};
|
||||
|
||||
export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
|
||||
if (photos.length >= 6) {
|
||||
// Show multiple photos once a 3x2 grid is available
|
||||
if (photos.length > 0) {
|
||||
return {
|
||||
openGraph: {
|
||||
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
|
||||
@ -157,21 +153,10 @@ export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
|
||||
images: ABSOLUTE_PATH_FOR_HOME_IMAGE,
|
||||
},
|
||||
};
|
||||
} else if (photos.length > 0) {
|
||||
// Otherwise show the first photo
|
||||
const photo = photos[0];
|
||||
return {
|
||||
openGraph: {
|
||||
images: absolutePathForPhotoImage(photo),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
images: absolutePathForPhotoImage(photo),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// If there are no photos, refrain from showing an OG image
|
||||
return {};
|
||||
}
|
||||
// If there are no photos, refrain from showing an OG image
|
||||
return {};
|
||||
};
|
||||
|
||||
const PHOTO_ID_FORWARDING_TABLE: Record<string, string> = JSON.parse(
|
||||
@ -184,6 +169,25 @@ export const translatePhotoId = (id: string) =>
|
||||
export const titleForPhoto = (photo: Photo) =>
|
||||
photo.title || 'Untitled';
|
||||
|
||||
const labelForPhotos = (photos: Photo[]) =>
|
||||
photos.length === 1 ? 'Photo' : 'Photos';
|
||||
|
||||
export const photoQuantityText = (photos: Photo[]) =>
|
||||
`(${photos.length} ${labelForPhotos(photos)})`;
|
||||
|
||||
export const descriptionForPhotoSet = (
|
||||
photos:Photo[],
|
||||
descriptor?: string,
|
||||
dateBased?: boolean,
|
||||
) =>
|
||||
dateBased
|
||||
? dateRangeForPhotos(photos).description.toUpperCase()
|
||||
: [
|
||||
photos.length,
|
||||
descriptor,
|
||||
labelForPhotos(photos),
|
||||
].join(' ');
|
||||
|
||||
export const dateRangeForPhotos = (photos: Photo[]) => {
|
||||
const start = photos[0].takenAtNaiveFormattedShort;
|
||||
const end = photos[photos.length - 1].takenAtNaiveFormattedShort;
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
parsePhotoFromDb,
|
||||
Photo,
|
||||
} from '@/photo';
|
||||
import { Camera, createCameraKey } from '@/camera';
|
||||
import { parameterize } from '@/utility/string';
|
||||
|
||||
const PHOTO_DEFAULT_LIMIT = 100;
|
||||
|
||||
@ -188,6 +190,19 @@ const sqlGetPhotosByTag = (
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const sqlGetPhotosByCamera = async (
|
||||
limit = PHOTO_DEFAULT_LIMIT,
|
||||
make: string,
|
||||
model: string,
|
||||
) => sql<PhotoDb>`
|
||||
SELECT * FROM photos
|
||||
WHERE
|
||||
LOWER(make)=${parameterize(make)} AND
|
||||
LOWER(REPLACE(model, ' ', '-'))=${parameterize(model)}
|
||||
ORDER BY taken_at DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const sqlGetPhotosTakenAfterDateInclusive = (
|
||||
takenAt: Date,
|
||||
limit?: number,
|
||||
@ -225,15 +240,26 @@ const sqlGetPhotosCountIncludingHidden = async () => sql`
|
||||
`.then(({ rows }) => parseInt(rows[0].count, 10));
|
||||
|
||||
const sqlGetUniqueTags = async () => sql`
|
||||
SELECT DISTINCT unnest(tags) FROM photos
|
||||
SELECT DISTINCT unnest(tags) as tag FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
`.then(({ rows }) => rows.map(row => row.unnest as string));
|
||||
ORDER BY tag ASC
|
||||
`.then(({ rows }) => rows.map(row => row.tag as string));
|
||||
|
||||
const sqlGetUniqueCameras = async () => sql`
|
||||
SELECT DISTINCT make||' '||model as camera, make, model FROM photos
|
||||
WHERE hidden IS NOT TRUE
|
||||
ORDER BY camera ASC
|
||||
`.then(({ rows }) => rows.map(({ make, model }) => ({
|
||||
cameraKey: createCameraKey(make, model),
|
||||
camera: { make, model } as Camera,
|
||||
})));
|
||||
|
||||
export type GetPhotosOptions = {
|
||||
sortBy?: 'createdAt' | 'takenAt' | 'priority'
|
||||
limit?: number
|
||||
offset?: number
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
takenBefore?: Date
|
||||
takenAfterInclusive?: Date
|
||||
includeHidden?: boolean
|
||||
@ -246,9 +272,7 @@ const safelyQueryPhotos = async <T>(callback: () => Promise<T>): Promise<T> => {
|
||||
result = await callback();
|
||||
} catch (e: any) {
|
||||
if (/relation "photos" does not exist/i.test(e.message)) {
|
||||
console.log(
|
||||
'Creating table "photos" because it did not exist',
|
||||
);
|
||||
console.log('Creating table "photos" because it did not exist');
|
||||
await sqlCreatePhotosTable();
|
||||
result = await callback();
|
||||
} else if (/endpoint is in transition/i.test(e.message)) {
|
||||
@ -275,6 +299,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
limit,
|
||||
offset,
|
||||
tag,
|
||||
camera,
|
||||
takenBefore,
|
||||
takenAfterInclusive,
|
||||
includeHidden,
|
||||
@ -291,6 +316,8 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
getPhotosSql = () => sqlGetPhotosTakenAfterDateInclusive(takenAfterInclusive, limit);
|
||||
} else if (tag) {
|
||||
getPhotosSql = () => sqlGetPhotosByTag(limit, offset, tag);
|
||||
} else if (camera) {
|
||||
getPhotosSql = () => sqlGetPhotosByCamera(limit, camera.make, camera.model);
|
||||
} else if (sortBy === 'createdAt') {
|
||||
getPhotosSql = () => sqlGetPhotosSortedByCreatedAt(limit, offset);
|
||||
} else if (sortBy === 'priority') {
|
||||
@ -316,3 +343,5 @@ export const getPhotosCountIncludingHidden = () =>
|
||||
safelyQueryPhotos(sqlGetPhotosCountIncludingHidden);
|
||||
|
||||
export const getUniqueTags = () => safelyQueryPhotos(sqlGetUniqueTags);
|
||||
|
||||
export const getUniqueCameras = () => safelyQueryPhotos(sqlGetUniqueCameras);
|
||||
|
||||
@ -1,55 +1,210 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { BASE_URL } from './config';
|
||||
import {
|
||||
Camera,
|
||||
createCameraKey,
|
||||
getMakeModelFromCameraString,
|
||||
} from '@/camera';
|
||||
|
||||
const PREFIX_PHOTO = '/p';
|
||||
const PREFIX_TAG = '/t';
|
||||
const PREFIX_ADMIN = '/admin';
|
||||
// Prefixes
|
||||
const PREFIX_PHOTO = '/p';
|
||||
const PREFIX_TAG = '/t';
|
||||
const PREFIX_CAMERA = '/shot-on';
|
||||
|
||||
// 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) =>
|
||||
const pathWithNext = (path: string, next?: number) =>
|
||||
next !== undefined ? `${path}?${NEXT}=${next}` : path;
|
||||
|
||||
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,
|
||||
camera?: Camera,
|
||||
) =>
|
||||
tag
|
||||
? `${pathForTag(tag)}/${photo.id}`
|
||||
: `${PREFIX_PHOTO}/${photo.id}`;
|
||||
? `${pathForTag(tag)}/${getPhotoId(photo)}`
|
||||
: camera
|
||||
? `${pathForCamera(camera)}/${getPhotoId(photo)}`
|
||||
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
|
||||
|
||||
export const pathForPhotoShare = (photo: Photo, tag?: string) =>
|
||||
`${pathForPhoto(photo, tag)}/${SHARE}`;
|
||||
export const pathForPhotoShare = (
|
||||
photo: PhotoOrPhotoId,
|
||||
tag?: string,
|
||||
camera?: Camera,
|
||||
) =>
|
||||
`${pathForPhoto(photo, tag, camera)}/${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 pathForTag = (tag: string) =>
|
||||
`${PREFIX_TAG}/${tag}`;
|
||||
|
||||
export const pathForTagShare = (tag: string) =>
|
||||
`${pathForTag(tag)}/${SHARE}`;
|
||||
|
||||
export const absolutePathForPhoto = (photo: Photo, tag?: string) =>
|
||||
`${BASE_URL}${pathForPhoto(photo, tag)}`;
|
||||
export const pathForCamera = ({ make, model }: Camera) =>
|
||||
`${PREFIX_CAMERA}/${createCameraKey(make, model)}`;
|
||||
|
||||
export const pathForCameraShare = (camera: Camera) =>
|
||||
`${pathForCamera(camera)}/${SHARE}`;
|
||||
|
||||
export const absolutePathForPhoto = (
|
||||
photo: PhotoOrPhotoId,
|
||||
tag?: string,
|
||||
camera?: Camera,
|
||||
) =>
|
||||
`${BASE_URL}${pathForPhoto(photo, tag, camera)}`;
|
||||
|
||||
export const absolutePathForTag = (tag: string) =>
|
||||
`${BASE_URL}${pathForTag(tag)}`;
|
||||
|
||||
export const absolutePathForPhotoImage = (photo: Photo) =>
|
||||
export const absolutePathForCamera= (camera: Camera) =>
|
||||
`${BASE_URL}${pathForCamera(camera)}`;
|
||||
|
||||
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
||||
`${absolutePathForPhoto(photo)}/image`;
|
||||
|
||||
export const absolutePathForTagImage = (tag: string) =>
|
||||
`${absolutePathForTag(tag)}/image`;
|
||||
|
||||
export const absolutePathForCameraImage= (camera: Camera) =>
|
||||
`${absolutePathForCamera(camera)}/image`;
|
||||
|
||||
// p/[photoId]
|
||||
export const isPathPhoto = (pathname = '') =>
|
||||
/^\/p\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// p/[photoId]/share
|
||||
export const isPathPhotoShare = (pathname = '') =>
|
||||
/^\/p\/[^/]+\/share\/?$/.test(pathname);
|
||||
|
||||
// t/[tag]
|
||||
export const isPathTag = (pathname = '') =>
|
||||
/^\/t\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// t/[tag]/share
|
||||
export const isPathTagShare = (pathname = '') =>
|
||||
/^\/t\/[^/]+\/share\/?$/.test(pathname);
|
||||
|
||||
// t/[tag]/[photoId]
|
||||
export const isPathTagPhoto = (pathname = '') =>
|
||||
/^\/t\/[^/]+\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// t/[tag]/[photoId]/share
|
||||
export const isPathTagPhotoShare = (pathname = '') =>
|
||||
/^\/t\/[^/]+\/[^/]+\/share\/?$/.test(pathname);
|
||||
|
||||
// shot-on/[camera]
|
||||
export const isPathCamera = (pathname = '') =>
|
||||
/^\/shot-on\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// shot-on/[camera]/share
|
||||
export const isPathCameraShare = (pathname = '') =>
|
||||
/^\/shot-on\/[^/]+\/share\/?$/.test(pathname);
|
||||
|
||||
// shot-on/[camera]/[photoId]
|
||||
export const isPathCameraPhoto = (pathname = '') =>
|
||||
/^\/shot-on\/[^/]+\/[^/]+\/?$/.test(pathname);
|
||||
|
||||
// shot-on/[camera]/[photoId]/share
|
||||
export const isPathCameraPhotoShare = (pathname = '') =>
|
||||
/^\/shot-on\/[^/]+\/[^/]+\/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
|
||||
camera?: Camera
|
||||
} => {
|
||||
const photoIdFromPhoto = pathname.match(/^\/p\/([^/]+)/)?.[1];
|
||||
const photoIdFromTag = pathname.match(/^\/t\/[^/]+\/((?!share)[^/]+)/)?.[1];
|
||||
// eslint-disable-next-line max-len
|
||||
const photoIdFromCamera = pathname.match(/^\/shot-on\/[^/]+\/((?!share)[^/]+)/)?.[1];
|
||||
const tag = pathname.match(/^\/t\/([^/]+)/)?.[1];
|
||||
const cameraString = pathname.match(/^\/shot-on\/([^/]+)/)?.[1];
|
||||
const camera = cameraString
|
||||
? getMakeModelFromCameraString(cameraString)
|
||||
: undefined;
|
||||
return {
|
||||
photoId: (
|
||||
photoIdFromPhoto ||
|
||||
photoIdFromTag ||
|
||||
photoIdFromCamera
|
||||
),
|
||||
tag,
|
||||
camera,
|
||||
};
|
||||
};
|
||||
|
||||
export const getEscapePath = (pathname?: string) => {
|
||||
const { photoId, tag, camera } = getPathComponents(pathname);
|
||||
if (
|
||||
(photoId && isPathPhoto(pathname)) ||
|
||||
(tag && isPathTag(pathname)) ||
|
||||
(camera && isPathCamera(pathname))
|
||||
) {
|
||||
return PATH_GRID;
|
||||
} else if (photoId && isPathTagPhotoShare(pathname)) {
|
||||
return pathForPhoto(photoId, tag);
|
||||
} else if (photoId && isPathCameraPhotoShare(pathname)) {
|
||||
return pathForPhoto(photoId, undefined, camera);
|
||||
} else if (photoId && isPathPhotoShare(pathname)) {
|
||||
return pathForPhoto(photoId);
|
||||
} else if (tag && (
|
||||
isPathTagPhoto(pathname) ||
|
||||
isPathTagShare(pathname)
|
||||
)) {
|
||||
return pathForTag(tag);
|
||||
} else if (camera && (
|
||||
isPathCameraPhoto(pathname) ||
|
||||
isPathCameraShare(pathname)
|
||||
)) {
|
||||
return pathForCamera(camera);
|
||||
}
|
||||
};
|
||||
|
||||
@ -5,22 +5,27 @@ import { cc } from '@/utility/css';
|
||||
|
||||
export default function PhotoTag({
|
||||
tag,
|
||||
showIcon = true,
|
||||
}: {
|
||||
tag: string
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
href={pathForTag(tag)}
|
||||
className="flex items-center gap-x-1.5 self-start"
|
||||
className={cc(
|
||||
'flex items-center gap-x-1.5 self-start',
|
||||
'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
)}
|
||||
>
|
||||
<FaTag
|
||||
size={11}
|
||||
className={cc(
|
||||
'flex-shrink-0',
|
||||
'text-gray-700 dark:text-gray-300 translate-y-[0.5px]',
|
||||
)}
|
||||
/>
|
||||
{showIcon &&
|
||||
<FaTag
|
||||
size={11}
|
||||
className={cc(
|
||||
'flex-shrink-0',
|
||||
'text-gray-700 dark:text-gray-300 translate-y-[0.5px]',
|
||||
)}
|
||||
/>}
|
||||
<span className="uppercase">
|
||||
{tag.replaceAll('-', ' ')}
|
||||
</span>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { Photo, dateRangeForPhotos } from '@/photo';
|
||||
import { cc } from '@/utility/css';
|
||||
import { Photo } from '@/photo';
|
||||
import PhotoTag from './PhotoTag';
|
||||
import { descriptionForTaggedPhotos } from '.';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import { pathForTagShare } from '@/site/paths';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
|
||||
export default function TagHeader({
|
||||
tag,
|
||||
@ -14,38 +13,14 @@ export default function TagHeader({
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
}) {
|
||||
const { start, end } = dateRangeForPhotos(photos);
|
||||
|
||||
const selectedPhotoIndex = selectedPhoto
|
||||
? photos.findIndex(photo => photo.id === selectedPhoto.id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={cc(
|
||||
'flex flex-col gap-y-0.5',
|
||||
'xs:grid grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
)}>
|
||||
<PhotoTag tag={tag} />
|
||||
<span className={cc(
|
||||
'inline-flex gap-2 items-center self-start',
|
||||
'uppercase text-gray-400 dark:text-gray-500',
|
||||
'sm:col-span-2 md:col-span-1 lg:col-span-2',
|
||||
)}>
|
||||
{selectedPhotoIndex !== undefined
|
||||
? `Tagged ${selectedPhotoIndex + 1} of ${photos.length}`
|
||||
: descriptionForTaggedPhotos(photos)}
|
||||
{selectedPhotoIndex === undefined &&
|
||||
<ShareButton path={pathForTagShare(tag)} dim />}
|
||||
</span>
|
||||
<span className={cc(
|
||||
'hidden sm:inline-block',
|
||||
'text-right uppercase',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}>
|
||||
{start === end
|
||||
? start
|
||||
: <>{start}<br />– {end}</>}
|
||||
</span>
|
||||
</div>
|
||||
<PhotoHeader
|
||||
entity={<PhotoTag tag={tag} />}
|
||||
entityVerb="Tagged"
|
||||
entityDescription={descriptionForTaggedPhotos(photos)}
|
||||
photos={photos}
|
||||
selectedPhoto={selectedPhoto}
|
||||
sharePath={pathForTagShare(tag)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
import { Photo, dateRangeForPhotos } from '@/photo';
|
||||
import { Photo, descriptionForPhotoSet, photoQuantityText } from '@/photo';
|
||||
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
|
||||
import { capitalizeWords } from '@/utility/string';
|
||||
|
||||
const labelForPhotos = (photos: Photo[]) =>
|
||||
photos.length === 1 ? 'Photo' : 'Photos';
|
||||
|
||||
export const titleForTag = (tag: string, photos:Photo[]) => [
|
||||
capitalizeWords(tag.replaceAll('-', ' ')),
|
||||
`(${photos.length} ${labelForPhotos(photos)})`,
|
||||
photoQuantityText(photos),
|
||||
].join(' ');
|
||||
|
||||
export const descriptionForTaggedPhotos = (
|
||||
photos:Photo[],
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
) =>
|
||||
dateBased
|
||||
? dateRangeForPhotos(photos).description.toUpperCase()
|
||||
: `${photos.length} Tagged ${labelForPhotos(photos)}`;
|
||||
descriptionForPhotoSet(photos, 'tagged', dateBased);
|
||||
|
||||
export const generateMetaForTag = (tag: string, photos: Photo[]) => ({
|
||||
url: absolutePathForTag(tag),
|
||||
|
||||
@ -15,3 +15,9 @@ export const capitalizeWords = (string: string) =>
|
||||
.split(' ')
|
||||
.map(capitalize)
|
||||
.join(' ');
|
||||
|
||||
export const parameterize = (string: string) =>
|
||||
string
|
||||
.trim()
|
||||
.replaceAll(' ', '-')
|
||||
.toLowerCase();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user