Merge pull request #1 from sambecker/next

Add camera-based views
This commit is contained in:
Sam Becker 2023-10-03 15:46:40 -05:00 committed by GitHub
commit 5f5d5b2116
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 3921 additions and 926 deletions

View File

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

122
__tests__/path.test.ts Normal file
View 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
View File

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

View File

@ -5,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

File diff suppressed because it is too large Load Diff

View File

@ -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>}

View File

@ -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 />

View File

@ -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 },
);
}

View File

@ -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>
);
}

View File

@ -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 },

View File

@ -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,

View File

@ -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} />;
}

View File

@ -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 />

View 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}
/>
</>;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View 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 }} />;
}

View 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 },
);
}

View 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>}
/>
);
}

View 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>}
/>
</>;
}

View File

@ -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 });

View File

@ -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} />;
}

View File

@ -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,

View File

@ -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">

View File

@ -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>

View File

@ -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 (

View File

@ -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
View File

@ -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'

View 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)}
/>
);
}

View 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,
}}/>
);
};

View 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>
);
};

View 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} />
&nbsp;
</>}
{!(hideApple && camera.make?.toLowerCase() === 'apple') &&
<>
{camera.make?.toLowerCase() === 'apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
size={14}
/>
: camera.make}
&nbsp;
</>}
{camera.model}
</Link>
);
}

31
src/camera/index.ts Normal file
View 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
View 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),
});

View File

@ -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={{

View File

@ -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} />
);
}

View 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 && <>&nbsp;</>}
{title}
</div>,
].concat(items)}
classNameItem="text-gray-400 dark:text-gray-500"
/>
);
}

View File

@ -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]',

View File

@ -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 (

View File

@ -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>

View File

@ -20,6 +20,7 @@ export default function ShareButton({
: undefined} />,
prefetch,
shouldScroll,
shouldReplace: true,
}} />
);
}

View File

@ -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>

View File

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

View File

@ -2,61 +2,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
View 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>
);
}

View File

@ -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}
/>

View File

@ -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) {

View File

@ -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

View File

@ -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>

View File

@ -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',

View File

@ -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
/>

View File

@ -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();
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
};

View File

@ -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>

View File

@ -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)}
/>
);
}

View File

@ -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),

View File

@ -15,3 +15,9 @@ export const capitalizeWords = (string: string) =>
.split(' ')
.map(capitalize)
.join(' ');
export const parameterize = (string: string) =>
string
.trim()
.replaceAll(' ', '-')
.toLowerCase();