Refine og image layouts, add X posting to share modal
This commit is contained in:
parent
8992f3455b
commit
567d59bf0e
@ -107,6 +107,7 @@ Application behavior can be changed by configuring the following environment var
|
||||
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
||||
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
|
||||
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
||||
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X button from share modal
|
||||
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results
|
||||
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
||||
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
|
||||
|
||||
24
__tests__/number.test.ts
Normal file
24
__tests__/number.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { roundToString, roundToNumber } from '@/utility/number';
|
||||
|
||||
describe('number', () => {
|
||||
describe('rounds to a', () => {
|
||||
it('string', () => {
|
||||
expect(roundToString(1.2345, 1)).toBe('1.2');
|
||||
expect(roundToString(1.2345, 2)).toBe('1.23');
|
||||
expect(roundToString(1.2355, 2)).toBe('1.24');
|
||||
expect(roundToString(1.2355, 3)).toBe('1.236');
|
||||
expect(roundToString(1.78, 1)).toBe('1.8');
|
||||
expect(roundToString(1.0, 1, false)).toBe('1');
|
||||
expect(roundToString(1.0, 1, true)).toBe('1.0');
|
||||
});
|
||||
it('number', () => {
|
||||
expect(roundToNumber(1.2345, 1)).toBe(1.2);
|
||||
expect(roundToNumber(1.2345, 2)).toBe(1.23);
|
||||
expect(roundToNumber(1.2355, 2)).toBe(1.24);
|
||||
expect(roundToNumber(1.2355, 3)).toBe(1.236);
|
||||
expect(roundToNumber(1.78, 1)).toBe(1.8);
|
||||
expect(roundToNumber(1.0, 1, false)).toBe(1);
|
||||
expect(roundToNumber(1.0, 1, true)).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -37,6 +37,9 @@ const SHARE = 'share';
|
||||
const PATH_ROOT = '/';
|
||||
const PATH_GRID = '/grid';
|
||||
const PATH_ADMIN = '/admin/photos';
|
||||
const PATH_OG = '/og';
|
||||
const PATH_OG_ALL = `${PATH_OG}/all`;
|
||||
const PATH_OG_SAMPLE = `${PATH_OG}/sample`;
|
||||
|
||||
const PATH_PHOTO = `/p/${PHOTO_ID}`;
|
||||
const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`;
|
||||
@ -77,6 +80,9 @@ describe('Paths', () => {
|
||||
expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false);
|
||||
// Private
|
||||
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_ALL)).toBe(true);
|
||||
expect(isPathProtected(PATH_OG_SAMPLE)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true);
|
||||
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true);
|
||||
|
||||
@ -7,7 +7,7 @@ import { getPhotosMeta } from '@/photo/db/query';
|
||||
import StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
|
||||
import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite';
|
||||
|
||||
export default async function GridPage() {
|
||||
export default async function OGPage() {
|
||||
const [
|
||||
photos,
|
||||
count,
|
||||
47
src/app/og/sample/page.tsx
Normal file
47
src/app/og/sample/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import CameraOGTile from '@/camera/CameraOGTile';
|
||||
import FocalLengthOGTile from '@/focal/FocalLengthOGTile';
|
||||
import PhotoOGTile from '@/photo/PhotoOGTile';
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import FilmSimulationOGTile from '@/simulation/FilmSimulationOGTile';
|
||||
import { TAG_FAVS } from '@/tag';
|
||||
import TagOGTile from '@/tag/TagOGTile';
|
||||
|
||||
const tag = 'cicadas';
|
||||
const camera = { make: 'Fujifilm', model: 'X-T5' };
|
||||
const cameraIcon = { make: 'Apple', model: 'iPhone 13 Pro' };
|
||||
const simulation = 'acros';
|
||||
const focal = 90;
|
||||
|
||||
export default async function OGOverviewPage() {
|
||||
const [
|
||||
photosBasic,
|
||||
photosIcon,
|
||||
photosTag,
|
||||
photosFavs,
|
||||
photosCamera,
|
||||
photosSimulation,
|
||||
photosFocal,
|
||||
] = await Promise.all([
|
||||
getPhotosCached({ limit: 1 }),
|
||||
getPhotosCached({ limit: 1, camera: cameraIcon }),
|
||||
getPhotosCached({ limit: 1, tag }),
|
||||
getPhotosCached({ limit: 1, tag: TAG_FAVS }),
|
||||
getPhotosCached({ limit: 1, camera }),
|
||||
getPhotosCached({ limit: 1, simulation }),
|
||||
getPhotosCached({ limit: 1, focal }),
|
||||
]);
|
||||
|
||||
console.log(photosIcon);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<PhotoOGTile photo={photosBasic[0]} />
|
||||
<PhotoOGTile photo={photosIcon[0]} />
|
||||
<TagOGTile tag={tag} photos={photosTag} />
|
||||
<TagOGTile tag={TAG_FAVS} photos={photosFavs} />
|
||||
<CameraOGTile camera={camera} photos={photosCamera} />
|
||||
<FilmSimulationOGTile simulation={simulation} photos={photosSimulation} />
|
||||
<FocalLengthOGTile focal={focal} photos={photosFocal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { Photo, PhotoDateRange } from '../photo';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import CameraOGTile from './CameraOGTile';
|
||||
import { Camera } from '.';
|
||||
import { shareTextForCamera } from './meta';
|
||||
|
||||
export default function CameraShareModal({
|
||||
camera,
|
||||
@ -17,9 +18,9 @@ export default function CameraShareModal({
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photos"
|
||||
pathShare={absolutePathForCamera(camera)}
|
||||
pathClose={pathForCamera(camera)}
|
||||
socialText={shareTextForCamera(camera, photos)}
|
||||
>
|
||||
<CameraOGTile {...{ camera, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
|
||||
@ -24,6 +24,15 @@ export const titleForCamera = (
|
||||
photoQuantityText(explicitCount ?? photos.length),
|
||||
].join(' ');
|
||||
|
||||
export const shareTextForCamera = (
|
||||
camera: Camera,
|
||||
photos: Photo[],
|
||||
) =>
|
||||
[
|
||||
'Photos shot on',
|
||||
formatCameraText(cameraFromPhoto(photos[0], camera)),
|
||||
].join(' ');
|
||||
|
||||
export const descriptionForCameraPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
|
||||
@ -118,8 +118,8 @@ export default function OGTile({
|
||||
/>}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'h-full flex flex-col gap-0.5 p-3',
|
||||
'font-sans leading-tight',
|
||||
'flex flex-col gap-1 p-3',
|
||||
'bg-gray-50 dark:bg-gray-900/50',
|
||||
'group-active:bg-gray-50 group-active:dark:bg-gray-900/50',
|
||||
'group-hover:bg-gray-100 group-hover:dark:bg-gray-900/70',
|
||||
|
||||
@ -7,55 +7,83 @@ import { BiCopy } from 'react-icons/bi';
|
||||
import { ReactNode } from 'react';
|
||||
import { shortenUrl } from '@/utility/url';
|
||||
import { toastSuccess } from '@/toast';
|
||||
import { PiXLogo } from 'react-icons/pi';
|
||||
import { SHOW_SOCIAL } from '@/site/config';
|
||||
import { generateXPostText } from '@/utility/social';
|
||||
|
||||
export default function ShareModal({
|
||||
title = 'Share',
|
||||
title,
|
||||
pathShare,
|
||||
pathClose,
|
||||
socialText,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
pathShare: string
|
||||
pathClose: string
|
||||
socialText: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const renderIcon = (
|
||||
icon: JSX.Element,
|
||||
action: () => void,
|
||||
embedded?: boolean,
|
||||
) =>
|
||||
<div
|
||||
className={clsx(
|
||||
'py-3 px-3.5',
|
||||
embedded ? 'border-l' : 'border rounded-md',
|
||||
'border-gray-200 bg-gray-50 active:bg-gray-100',
|
||||
// eslint-disable-next-line max-len
|
||||
'dark:border-gray-800 dark:bg-gray-900/75 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
onClick={action}
|
||||
>
|
||||
{icon}
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<Modal onClosePath={pathClose}>
|
||||
<div className="space-y-3 md:space-y-4 w-full">
|
||||
<div className={clsx(
|
||||
'flex items-center gap-x-3',
|
||||
'text-2xl leading-snug',
|
||||
)}>
|
||||
<TbPhotoShare size={22} className="hidden xs:block" />
|
||||
<div className="flex-grow">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{title &&
|
||||
<div className={clsx(
|
||||
'flex items-center gap-x-3',
|
||||
'text-2xl leading-snug',
|
||||
)}>
|
||||
<TbPhotoShare size={22} className="hidden xs:block" />
|
||||
<div className="flex-grow">
|
||||
{title}
|
||||
</div>
|
||||
</div>}
|
||||
{children}
|
||||
<div className={clsx(
|
||||
'rounded-md',
|
||||
'w-full overflow-hidden',
|
||||
'flex items-center justify-stretch',
|
||||
'border border-gray-200 dark:border-gray-800',
|
||||
)}>
|
||||
<div className="truncate p-2 w-full">
|
||||
{shortenUrl(pathShare)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'p-3 border-l',
|
||||
'border-gray-200 bg-gray-100 active:bg-gray-200',
|
||||
// eslint-disable-next-line max-len
|
||||
'dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
|
||||
'cursor-pointer',
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx(
|
||||
'rounded-md',
|
||||
'w-full overflow-hidden',
|
||||
'flex items-center justify-stretch',
|
||||
'border border-gray-200 dark:border-gray-800',
|
||||
)}>
|
||||
<div className="truncate p-2 w-full">
|
||||
{shortenUrl(pathShare)}
|
||||
</div>
|
||||
{renderIcon(
|
||||
<BiCopy size={18} />,
|
||||
() => {
|
||||
navigator.clipboard.writeText(pathShare);
|
||||
toastSuccess('Link to photo copied');
|
||||
},
|
||||
true,
|
||||
)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(pathShare);
|
||||
toastSuccess('Link to photo copied');
|
||||
}}
|
||||
>
|
||||
<BiCopy size={18} />
|
||||
</div>
|
||||
{SHOW_SOCIAL &&
|
||||
renderIcon(
|
||||
<PiXLogo size={18} />,
|
||||
() => window.open(
|
||||
generateXPostText(pathShare, socialText),
|
||||
'_blank',
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -2,6 +2,7 @@ import { absolutePathForFocalLength, pathForFocalLength } from '@/site/paths';
|
||||
import { Photo, PhotoDateRange } from '../photo';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import FocalLengthOGTile from './FocalLengthOGTile';
|
||||
import { shareTextFocalLength } from '.';
|
||||
|
||||
export default function FocalLengthShareModal({
|
||||
focal,
|
||||
@ -16,9 +17,9 @@ export default function FocalLengthShareModal({
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photos"
|
||||
pathShare={absolutePathForFocalLength(focal)}
|
||||
pathClose={pathForFocalLength(focal)}
|
||||
socialText={shareTextFocalLength(focal)}
|
||||
>
|
||||
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
|
||||
@ -27,6 +27,9 @@ export const titleForFocalLength = (
|
||||
photoQuantityText(explicitCount ?? photos.length),
|
||||
].join(' ');
|
||||
|
||||
export const shareTextFocalLength = (focal: number) =>
|
||||
`Photos shot at ${formatFocalLength(focal)}`;
|
||||
|
||||
export const descriptionForFocalLengthPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
|
||||
@ -33,14 +33,19 @@ export default function CameraImageResponse({
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
<IoMdCamera
|
||||
size={height * .09}
|
||||
style={{ transform: `translateY(${height * 0.002}px)` }}
|
||||
/>
|
||||
<span style={{textTransform: 'uppercase'}}>
|
||||
{formatCameraText(camera)}
|
||||
</span>
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <IoMdCamera
|
||||
size={height * .079}
|
||||
style={{
|
||||
transform: `translateY(${height * .003}px)`,
|
||||
marginRight: height * .015,
|
||||
}}
|
||||
/>,
|
||||
}}>
|
||||
{formatCameraText(camera).toLocaleUpperCase()}
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
|
||||
@ -36,15 +36,17 @@ export default function FilmSimulationImageResponse({
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
<PhotoFilmSimulationIcon
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <PhotoFilmSimulationIcon
|
||||
simulation={simulation}
|
||||
height={40}
|
||||
style={{ marginRight: -10 }}
|
||||
/>
|
||||
<span style={{ textTransform: 'uppercase' }}>
|
||||
{labelForFilmSimulation(simulation).medium}
|
||||
</span>
|
||||
height={height * .081}
|
||||
style={{ transform: `translateY(${height * .001}px)`}}
|
||||
/>,
|
||||
}}>
|
||||
{labelForFilmSimulation(simulation).medium.toLocaleUpperCase()}
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
|
||||
@ -32,14 +32,19 @@ export default function FocalLengthImageResponse({
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
<TbCone
|
||||
size={height * .08}
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: <TbCone
|
||||
size={height * .075}
|
||||
style={{
|
||||
transform: `translateY(${height * .01}px) rotate(270deg)`,
|
||||
transform: `translateY(${height * .007}px) rotate(270deg)`,
|
||||
marginRight: height * .01,
|
||||
}}
|
||||
/>
|
||||
<span>{formatFocalLength(focal)}</span>
|
||||
/>,
|
||||
}}>
|
||||
{formatFocalLength(focal)}
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
|
||||
@ -20,9 +20,16 @@ export default function PhotoImageResponse({
|
||||
fontFamily: string
|
||||
isNextImageReady: boolean
|
||||
}) {
|
||||
const model = photo.model
|
||||
? formatCameraModelTextShort(cameraFromPhoto(photo))
|
||||
: undefined;
|
||||
const caption = [
|
||||
photo.model
|
||||
? formatCameraModelTextShort(cameraFromPhoto(photo))
|
||||
: undefined,
|
||||
photo.focalLengthFormatted,
|
||||
photo.fNumberFormatted,
|
||||
photo.isoFormatted,
|
||||
]
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<ImageContainer {...{ width, height }}>
|
||||
@ -33,24 +40,15 @@ export default function PhotoImageResponse({
|
||||
...OG_TEXT_BOTTOM_ALIGNMENT && { imagePosition: 'top' },
|
||||
}} />
|
||||
{shouldShowExifDataForPhoto(photo) &&
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
{photo.make === 'Apple' &&
|
||||
<div style={{ display: 'flex' }}>
|
||||
<AiFillApple />
|
||||
</div>}
|
||||
{model &&
|
||||
<div style={{ display: 'flex' }}>
|
||||
{model}
|
||||
</div>}
|
||||
<div style={{ display: 'flex' }}>
|
||||
{photo.focalLengthFormatted}
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{photo.fNumberFormatted}
|
||||
</div>
|
||||
<div>
|
||||
{photo.isoFormatted}
|
||||
</div>
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
...photo.make === 'Apple' && { icon: <AiFillApple style={{
|
||||
marginRight: height * .01,
|
||||
}} /> },
|
||||
}}>
|
||||
{caption}
|
||||
</ImageCaption>}
|
||||
</ImageContainer>
|
||||
);
|
||||
|
||||
@ -32,21 +32,29 @@ export default function TagImageResponse({
|
||||
height,
|
||||
}}
|
||||
/>
|
||||
<ImageCaption {...{ width, height, fontFamily }}>
|
||||
{isTagFavs(tag)
|
||||
<ImageCaption {...{
|
||||
width,
|
||||
height,
|
||||
fontFamily,
|
||||
icon: isTagFavs(tag)
|
||||
? <FaStar
|
||||
size={height * .074}
|
||||
size={height * .066}
|
||||
style={{
|
||||
transform: `translateY(${height * .01}px)`,
|
||||
transform: `translateY(${height * .0095}px)`,
|
||||
// Fix horizontal distortion in icon size
|
||||
width: height * .08,
|
||||
width: height * .076,
|
||||
marginRight: height * .01,
|
||||
}}
|
||||
/>
|
||||
: <FaTag
|
||||
size={height * .067}
|
||||
style={{ transform: `translateY(${height * .02}px)` }}
|
||||
/>}
|
||||
<span>{tag.toUpperCase()}</span>
|
||||
size={height * .06}
|
||||
style={{
|
||||
transform: `translateY(${height * .016}px)`,
|
||||
marginRight: height * .015,
|
||||
}}
|
||||
/>,
|
||||
}}>
|
||||
{tag.toLocaleUpperCase()}
|
||||
</ImageCaption>
|
||||
</ImageContainer>
|
||||
);
|
||||
|
||||
@ -6,59 +6,50 @@ const GRADIENT_STOPS = 'rgba(0,0,0,0), rgba(0,0,0,0.3), rgba(0,0,0,0.7)';
|
||||
export default function ImageCaption({
|
||||
height,
|
||||
fontFamily,
|
||||
subhead,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
fontFamily: string
|
||||
subhead?: ReactNode
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const paddingEdge = height * .07;
|
||||
const paddingContent = height * .6;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'absolute',
|
||||
paddingLeft: height * .0875,
|
||||
paddingRight: height * .0875,
|
||||
color: 'white',
|
||||
backgroundBlendMode: 'multiply',
|
||||
fontFamily,
|
||||
fontSize: height *.089,
|
||||
fontSize: height *.08,
|
||||
gap: '1rem', // Mimic mono font space metric
|
||||
lineHeight: 1,
|
||||
left: 0,
|
||||
right: 0,
|
||||
...OG_TEXT_BOTTOM_ALIGNMENT
|
||||
? {
|
||||
paddingTop: height * .6,
|
||||
paddingBottom: height * .075,
|
||||
paddingTop: paddingContent,
|
||||
paddingBottom: paddingEdge,
|
||||
background: `linear-gradient(to bottom, ${GRADIENT_STOPS})`,
|
||||
bottom: 0,
|
||||
}
|
||||
: {
|
||||
paddingTop: height * .075,
|
||||
paddingBottom: height * .6,
|
||||
paddingTop: paddingEdge,
|
||||
paddingBottom: paddingContent,
|
||||
background: `linear-gradient(to top, ${GRADIENT_STOPS})`,
|
||||
top: 0,
|
||||
},
|
||||
}}>
|
||||
{subhead &&
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: height * .053,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{subhead}
|
||||
</div>}
|
||||
{icon}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: height * .053,
|
||||
gap: height * .048,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
@ -4,15 +4,20 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import {
|
||||
PATH_ADMIN,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_OG,
|
||||
PATH_OG_SAMPLE,
|
||||
PREFIX_PHOTO,
|
||||
PREFIX_TAG,
|
||||
} from './site/paths';
|
||||
|
||||
export default function middleware(req: NextRequest, res:NextResponse) {
|
||||
console.log('MIDDLEWARE', req.nextUrl.pathname);
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
if (pathname === PATH_ADMIN) {
|
||||
return NextResponse.redirect(new URL(PATH_ADMIN_PHOTOS, req.url));
|
||||
} else if (pathname === PATH_OG) {
|
||||
return NextResponse.redirect(new URL(PATH_OG_SAMPLE, req.url));
|
||||
} else if (/^\/photos\/(.)+$/.test(pathname)) {
|
||||
// Accept /photos/* paths, but serve /p/*
|
||||
const matches = pathname.match(/^\/photos\/(.+)$/);
|
||||
|
||||
@ -14,9 +14,9 @@ export default function PhotoShareModal(props: {
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photo"
|
||||
pathShare={absolutePathForPhoto(props)}
|
||||
pathClose={pathForPhoto(props)}
|
||||
socialText="Check out this photo"
|
||||
>
|
||||
<PhotoOGTile photo={props.photo} />
|
||||
</ShareModal>
|
||||
|
||||
@ -65,7 +65,7 @@ export default function StaggeredOgPhotos({
|
||||
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
|
||||
onVisible={index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
:undefined}
|
||||
: undefined}
|
||||
riseOnHover
|
||||
/>)}
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
generateLocalPostgresString,
|
||||
} from '@/utility/date';
|
||||
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
|
||||
import { toFixedNumber } from '@/utility/number';
|
||||
import { roundToNumber } from '@/utility/number';
|
||||
import { convertStringToArray } from '@/utility/string';
|
||||
import { generateNanoid } from '@/utility/nanoid';
|
||||
import {
|
||||
@ -251,7 +251,7 @@ export const convertFormDataToPhotoDbInsert = (
|
||||
// Convert form strings to arrays
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
// Convert form strings to numbers
|
||||
aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6),
|
||||
aspectRatio: roundToNumber(parseFloat(photoForm.aspectRatio), 6),
|
||||
focalLength: photoForm.focalLength
|
||||
? parseInt(photoForm.focalLength)
|
||||
: undefined,
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
import { Photo, PhotoDateRange } from '../photo';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import FilmSimulationOGTile from './FilmSimulationOGTile';
|
||||
import { FilmSimulation } from '.';
|
||||
import { FilmSimulation, shareTextForFilmSimulation } from '.';
|
||||
|
||||
export default function FilmSimulationShareModal({
|
||||
simulation,
|
||||
@ -20,9 +20,9 @@ export default function FilmSimulationShareModal({
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photos"
|
||||
pathShare={absolutePathForFilmSimulation(simulation)}
|
||||
pathClose={pathForFilmSimulation(simulation)}
|
||||
socialText={shareTextForFilmSimulation(simulation)}
|
||||
>
|
||||
<FilmSimulationOGTile {...{ simulation, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
|
||||
@ -40,6 +40,11 @@ export const titleForFilmSimulation = (
|
||||
photoQuantityText(explicitCount ?? photos.length),
|
||||
].join(' ');
|
||||
|
||||
export const shareTextForFilmSimulation = (
|
||||
simulation: FilmSimulation,
|
||||
) =>
|
||||
`Photos shot on Fujifilm ${labelForFilmSimulation(simulation).large}`;
|
||||
|
||||
export const descriptionForFilmSimulationPhotos = (
|
||||
photos: Photo[],
|
||||
dateBased?: boolean,
|
||||
|
||||
@ -38,6 +38,7 @@ export default function SiteChecklistClient({
|
||||
hasTitle,
|
||||
hasDomain,
|
||||
showRepoLink,
|
||||
showSocial,
|
||||
showFilmSimulations,
|
||||
showExifInfo,
|
||||
isProModeEnabled,
|
||||
@ -438,6 +439,17 @@ export default function SiteChecklistClient({
|
||||
Set environment variable to {'"1"'} to hide footer link:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show social"
|
||||
status={showSocial}
|
||||
isPending={isPendingPage}
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to hide
|
||||
{' '}
|
||||
X button from share modal:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_SOCIAL'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show Fujifilm simulations"
|
||||
status={showFilmSimulations}
|
||||
|
||||
@ -130,6 +130,8 @@ export const PUBLIC_API_ENABLED =
|
||||
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
||||
export const SHOW_REPO_LINK =
|
||||
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
||||
export const SHOW_SOCIAL =
|
||||
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
|
||||
export const SHOW_FILM_SIMULATIONS =
|
||||
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
|
||||
export const SHOW_EXIF_DATA =
|
||||
@ -170,6 +172,7 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
|
||||
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
|
||||
showRepoLink: SHOW_REPO_LINK,
|
||||
showSocial: SHOW_SOCIAL,
|
||||
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
||||
showExifInfo: SHOW_EXIF_DATA,
|
||||
isProModeEnabled: PRO_MODE_ENABLED,
|
||||
|
||||
@ -6,26 +6,27 @@ import { parameterize } from '@/utility/string';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
|
||||
// Core paths
|
||||
export const PATH_ROOT = '/';
|
||||
export const PATH_GRID = '/grid';
|
||||
export const PATH_ADMIN = '/admin';
|
||||
export const PATH_API = '/api';
|
||||
export const PATH_SIGN_IN = '/sign-in';
|
||||
export const PATH_OG = '/og';
|
||||
export const PATH_ROOT = '/';
|
||||
export const PATH_GRID = '/grid';
|
||||
export const PATH_ADMIN = '/admin';
|
||||
export const PATH_API = '/api';
|
||||
export const PATH_SIGN_IN = '/sign-in';
|
||||
export const PATH_OG = '/og';
|
||||
|
||||
// Path prefixes
|
||||
export const PREFIX_PHOTO = '/p';
|
||||
export const PREFIX_TAG = '/tag';
|
||||
export const PREFIX_CAMERA = '/shot-on';
|
||||
export const PREFIX_FILM_SIMULATION = '/film';
|
||||
export const PREFIX_FOCAL_LENGTH = '/focal';
|
||||
export const PREFIX_PHOTO = '/p';
|
||||
export const PREFIX_TAG = '/tag';
|
||||
export const PREFIX_CAMERA = '/shot-on';
|
||||
export const PREFIX_FILM_SIMULATION = '/film';
|
||||
export const PREFIX_FOCAL_LENGTH = '/focal';
|
||||
|
||||
// Dynamic paths
|
||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
|
||||
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
||||
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
|
||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
|
||||
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
||||
// eslint-disable-next-line max-len
|
||||
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
|
||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||
|
||||
// Admin paths
|
||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
||||
@ -34,6 +35,10 @@ export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
|
||||
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
|
||||
|
||||
// Debug paths
|
||||
export const PATH_OG_ALL = `${PATH_OG}/all`;
|
||||
export const PATH_OG_SAMPLE = `${PATH_OG}/sample`;
|
||||
|
||||
// API paths
|
||||
export const PATH_API_STORAGE = `${PATH_API}/storage`;
|
||||
export const PATH_API_VERCEL_BLOB_UPLOAD = `${PATH_API_STORAGE}/vercel-blob`;
|
||||
@ -261,7 +266,7 @@ export const isPathAdminConfiguration = (pathname?: string) =>
|
||||
export const isPathProtected = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||
|
||||
pathname === PATH_OG;
|
||||
checkPathPrefix(pathname, PATH_OG);
|
||||
|
||||
export const getPathComponents = (pathname = ''): {
|
||||
photoId?: string
|
||||
|
||||
@ -2,6 +2,7 @@ import { absolutePathForTag, pathForTag } from '@/site/paths';
|
||||
import { Photo, PhotoDateRange } from '../photo';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import TagOGTile from './TagOGTile';
|
||||
import { shareTextForTag } from '.';
|
||||
|
||||
export default function TagShareModal({
|
||||
tag,
|
||||
@ -16,9 +17,9 @@ export default function TagShareModal({
|
||||
}) {
|
||||
return (
|
||||
<ShareModal
|
||||
title="Share Photos"
|
||||
pathShare={absolutePathForTag(tag)}
|
||||
pathClose={pathForTag(tag)}
|
||||
socialText={shareTextForTag(tag)}
|
||||
>
|
||||
<TagOGTile {...{ tag, photos, count, dateRange }} />
|
||||
</ShareModal>
|
||||
|
||||
@ -11,9 +11,9 @@ import {
|
||||
} from '@/site/paths';
|
||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
||||
|
||||
// Reserved/virtual tags
|
||||
export const TAG_FAVS = 'favs'; // Reserved
|
||||
export const TAG_HIDDEN = 'hidden'; // Virtual
|
||||
// Reserved tags
|
||||
export const TAG_FAVS = 'favs';
|
||||
export const TAG_HIDDEN = 'hidden';
|
||||
|
||||
export type TagsWithMeta = {
|
||||
tag: string
|
||||
@ -24,10 +24,7 @@ export const formatTag = (tag?: string) =>
|
||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||
|
||||
export const doesStringContainReservedTags = (tags?: string) =>
|
||||
convertStringToArray(tags)?.some(tag => (
|
||||
isTagFavs(tag) ||
|
||||
tag.toLowerCase() === TAG_HIDDEN
|
||||
));
|
||||
convertStringToArray(tags)?.some(tag => isTagFavs(tag) || isTagHidden(tag));
|
||||
|
||||
export const titleForTag = (
|
||||
tag: string,
|
||||
@ -38,6 +35,9 @@ export const titleForTag = (
|
||||
photoQuantityText(explicitCount ?? photos.length),
|
||||
].join(' ');
|
||||
|
||||
export const shareTextForTag = (tag: string) =>
|
||||
isTagFavs(tag) ? 'Favorite photos' : `Photos tagged '${tag}'`;
|
||||
|
||||
export const sortTags = (
|
||||
tags: string[],
|
||||
tagToHide?: string,
|
||||
@ -92,6 +92,8 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||
export const isPathFavs = (pathname?: string) =>
|
||||
getPathComponents(pathname).tag === TAG_FAVS;
|
||||
|
||||
export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN;
|
||||
|
||||
export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
||||
if (hiddenPhotosCount > 0) {
|
||||
return tags
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { OrientationTypes, type ExifData } from 'ts-exif-parser';
|
||||
import { formatNumberToFraction } from './number';
|
||||
import { formatNumberToFraction, roundToString } from './number';
|
||||
|
||||
const OFFSET_REGEX = /[+-]\d\d:\d\d/;
|
||||
|
||||
@ -32,10 +32,12 @@ export const getAspectRatioFromExif = (data: ExifData): number => {
|
||||
};
|
||||
|
||||
export const formatAperture = (aperture?: number) =>
|
||||
aperture ? `ƒ/${aperture}` : undefined;
|
||||
aperture
|
||||
? `ƒ/${roundToString(aperture)}`
|
||||
: undefined;
|
||||
|
||||
export const formatIso = (iso?: number) =>
|
||||
iso ? `ISO ${iso}` : undefined;
|
||||
iso ? `ISO ${iso.toLocaleString()}` : undefined;
|
||||
|
||||
export const formatExposureTime = (exposureTime = 0) =>
|
||||
exposureTime > 0
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
export const toFixedNumber = (
|
||||
export const roundToString = (
|
||||
number: number,
|
||||
digits: number,
|
||||
base = 10) => {
|
||||
const pow = Math.pow(base ?? 10, digits);
|
||||
return Math.round(number * pow) / pow;
|
||||
place = 1,
|
||||
includeZero?: boolean,
|
||||
) => {
|
||||
const precision = Math.pow(10, place);
|
||||
const result = Math.round(number * precision) / precision;
|
||||
return includeZero ? result.toFixed(place) : result.toString();
|
||||
};
|
||||
|
||||
export const roundToNumber = (
|
||||
...args: Parameters<typeof roundToString>
|
||||
) =>
|
||||
parseFloat(roundToString(...args));
|
||||
|
||||
const gcd = (a: number, b: number): number => {
|
||||
if (b <= 0.0000001) {
|
||||
return a;
|
||||
|
||||
6
src/utility/social.ts
Normal file
6
src/utility/social.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const generateXPostText = (path: string, text: string) => {
|
||||
const url = new URL('https://x.com/intent/post');
|
||||
url.searchParams.set('text', text);
|
||||
url.searchParams.set('url', path);
|
||||
return url.toString();
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user