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_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
||||||
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
|
- `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_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_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_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)
|
- `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_ROOT = '/';
|
||||||
const PATH_GRID = '/grid';
|
const PATH_GRID = '/grid';
|
||||||
const PATH_ADMIN = '/admin/photos';
|
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 = `/p/${PHOTO_ID}`;
|
||||||
const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`;
|
const PATH_PHOTO_SHARE = `${PATH_PHOTO}/${SHARE}`;
|
||||||
@ -77,6 +80,9 @@ describe('Paths', () => {
|
|||||||
expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false);
|
expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false);
|
||||||
// Private
|
// Private
|
||||||
expect(isPathProtected(PATH_ADMIN)).toBe(true);
|
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)).toBe(true);
|
||||||
expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true);
|
expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true);
|
||||||
expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).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 StaggeredOgPhotos from '@/photo/StaggeredOgPhotos';
|
||||||
import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite';
|
import StaggeredOgPhotosInfinite from '@/photo/StaggeredOgPhotosInfinite';
|
||||||
|
|
||||||
export default async function GridPage() {
|
export default async function OGPage() {
|
||||||
const [
|
const [
|
||||||
photos,
|
photos,
|
||||||
count,
|
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 ShareModal from '@/components/ShareModal';
|
||||||
import CameraOGTile from './CameraOGTile';
|
import CameraOGTile from './CameraOGTile';
|
||||||
import { Camera } from '.';
|
import { Camera } from '.';
|
||||||
|
import { shareTextForCamera } from './meta';
|
||||||
|
|
||||||
export default function CameraShareModal({
|
export default function CameraShareModal({
|
||||||
camera,
|
camera,
|
||||||
@ -17,9 +18,9 @@ export default function CameraShareModal({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title="Share Photos"
|
|
||||||
pathShare={absolutePathForCamera(camera)}
|
pathShare={absolutePathForCamera(camera)}
|
||||||
pathClose={pathForCamera(camera)}
|
pathClose={pathForCamera(camera)}
|
||||||
|
socialText={shareTextForCamera(camera, photos)}
|
||||||
>
|
>
|
||||||
<CameraOGTile {...{ camera, photos, count, dateRange }} />
|
<CameraOGTile {...{ camera, photos, count, dateRange }} />
|
||||||
</ShareModal>
|
</ShareModal>
|
||||||
|
|||||||
@ -24,6 +24,15 @@ export const titleForCamera = (
|
|||||||
photoQuantityText(explicitCount ?? photos.length),
|
photoQuantityText(explicitCount ?? photos.length),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
export const shareTextForCamera = (
|
||||||
|
camera: Camera,
|
||||||
|
photos: Photo[],
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
'Photos shot on',
|
||||||
|
formatCameraText(cameraFromPhoto(photos[0], camera)),
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
export const descriptionForCameraPhotos = (
|
export const descriptionForCameraPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
|
|||||||
@ -118,8 +118,8 @@ export default function OGTile({
|
|||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
|
'h-full flex flex-col gap-0.5 p-3',
|
||||||
'font-sans leading-tight',
|
'font-sans leading-tight',
|
||||||
'flex flex-col gap-1 p-3',
|
|
||||||
'bg-gray-50 dark:bg-gray-900/50',
|
'bg-gray-50 dark:bg-gray-900/50',
|
||||||
'group-active:bg-gray-50 group-active: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',
|
'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 { ReactNode } from 'react';
|
||||||
import { shortenUrl } from '@/utility/url';
|
import { shortenUrl } from '@/utility/url';
|
||||||
import { toastSuccess } from '@/toast';
|
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({
|
export default function ShareModal({
|
||||||
title = 'Share',
|
title,
|
||||||
pathShare,
|
pathShare,
|
||||||
pathClose,
|
pathClose,
|
||||||
|
socialText,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
pathShare: string
|
pathShare: string
|
||||||
pathClose: string
|
pathClose: string
|
||||||
|
socialText: string
|
||||||
children: ReactNode
|
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 (
|
return (
|
||||||
<Modal onClosePath={pathClose}>
|
<Modal onClosePath={pathClose}>
|
||||||
<div className="space-y-3 md:space-y-4 w-full">
|
<div className="space-y-3 md:space-y-4 w-full">
|
||||||
<div className={clsx(
|
{title &&
|
||||||
'flex items-center gap-x-3',
|
<div className={clsx(
|
||||||
'text-2xl leading-snug',
|
'flex items-center gap-x-3',
|
||||||
)}>
|
'text-2xl leading-snug',
|
||||||
<TbPhotoShare size={22} className="hidden xs:block" />
|
)}>
|
||||||
<div className="flex-grow">
|
<TbPhotoShare size={22} className="hidden xs:block" />
|
||||||
{title}
|
<div className="flex-grow">
|
||||||
</div>
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
</div>}
|
||||||
{children}
|
{children}
|
||||||
<div className={clsx(
|
<div className="flex items-center gap-2">
|
||||||
'rounded-md',
|
<div className={clsx(
|
||||||
'w-full overflow-hidden',
|
'rounded-md',
|
||||||
'flex items-center justify-stretch',
|
'w-full overflow-hidden',
|
||||||
'border border-gray-200 dark:border-gray-800',
|
'flex items-center justify-stretch',
|
||||||
)}>
|
'border border-gray-200 dark:border-gray-800',
|
||||||
<div className="truncate p-2 w-full">
|
)}>
|
||||||
{shortenUrl(pathShare)}
|
<div className="truncate p-2 w-full">
|
||||||
</div>
|
{shortenUrl(pathShare)}
|
||||||
<div
|
</div>
|
||||||
className={clsx(
|
{renderIcon(
|
||||||
'p-3 border-l',
|
<BiCopy size={18} />,
|
||||||
'border-gray-200 bg-gray-100 active:bg-gray-200',
|
() => {
|
||||||
// eslint-disable-next-line max-len
|
navigator.clipboard.writeText(pathShare);
|
||||||
'dark:border-gray-800 dark:bg-gray-900 dark:hover:bg-gray-800/75 dark:active:bg-gray-900',
|
toastSuccess('Link to photo copied');
|
||||||
'cursor-pointer',
|
},
|
||||||
|
true,
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(pathShare);
|
|
||||||
toastSuccess('Link to photo copied');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BiCopy size={18} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{SHOW_SOCIAL &&
|
||||||
|
renderIcon(
|
||||||
|
<PiXLogo size={18} />,
|
||||||
|
() => window.open(
|
||||||
|
generateXPostText(pathShare, socialText),
|
||||||
|
'_blank',
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { absolutePathForFocalLength, pathForFocalLength } from '@/site/paths';
|
|||||||
import { Photo, PhotoDateRange } from '../photo';
|
import { Photo, PhotoDateRange } from '../photo';
|
||||||
import ShareModal from '@/components/ShareModal';
|
import ShareModal from '@/components/ShareModal';
|
||||||
import FocalLengthOGTile from './FocalLengthOGTile';
|
import FocalLengthOGTile from './FocalLengthOGTile';
|
||||||
|
import { shareTextFocalLength } from '.';
|
||||||
|
|
||||||
export default function FocalLengthShareModal({
|
export default function FocalLengthShareModal({
|
||||||
focal,
|
focal,
|
||||||
@ -16,9 +17,9 @@ export default function FocalLengthShareModal({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title="Share Photos"
|
|
||||||
pathShare={absolutePathForFocalLength(focal)}
|
pathShare={absolutePathForFocalLength(focal)}
|
||||||
pathClose={pathForFocalLength(focal)}
|
pathClose={pathForFocalLength(focal)}
|
||||||
|
socialText={shareTextFocalLength(focal)}
|
||||||
>
|
>
|
||||||
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
||||||
</ShareModal>
|
</ShareModal>
|
||||||
|
|||||||
@ -27,6 +27,9 @@ export const titleForFocalLength = (
|
|||||||
photoQuantityText(explicitCount ?? photos.length),
|
photoQuantityText(explicitCount ?? photos.length),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
export const shareTextFocalLength = (focal: number) =>
|
||||||
|
`Photos shot at ${formatFocalLength(focal)}`;
|
||||||
|
|
||||||
export const descriptionForFocalLengthPhotos = (
|
export const descriptionForFocalLengthPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
|
|||||||
@ -33,14 +33,19 @@ export default function CameraImageResponse({
|
|||||||
height,
|
height,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{
|
||||||
<IoMdCamera
|
width,
|
||||||
size={height * .09}
|
height,
|
||||||
style={{ transform: `translateY(${height * 0.002}px)` }}
|
fontFamily,
|
||||||
/>
|
icon: <IoMdCamera
|
||||||
<span style={{textTransform: 'uppercase'}}>
|
size={height * .079}
|
||||||
{formatCameraText(camera)}
|
style={{
|
||||||
</span>
|
transform: `translateY(${height * .003}px)`,
|
||||||
|
marginRight: height * .015,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
}}>
|
||||||
|
{formatCameraText(camera).toLocaleUpperCase()}
|
||||||
</ImageCaption>
|
</ImageCaption>
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -36,15 +36,17 @@ export default function FilmSimulationImageResponse({
|
|||||||
height,
|
height,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{
|
||||||
<PhotoFilmSimulationIcon
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
icon: <PhotoFilmSimulationIcon
|
||||||
simulation={simulation}
|
simulation={simulation}
|
||||||
height={40}
|
height={height * .081}
|
||||||
style={{ marginRight: -10 }}
|
style={{ transform: `translateY(${height * .001}px)`}}
|
||||||
/>
|
/>,
|
||||||
<span style={{ textTransform: 'uppercase' }}>
|
}}>
|
||||||
{labelForFilmSimulation(simulation).medium}
|
{labelForFilmSimulation(simulation).medium.toLocaleUpperCase()}
|
||||||
</span>
|
|
||||||
</ImageCaption>
|
</ImageCaption>
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -32,14 +32,19 @@ export default function FocalLengthImageResponse({
|
|||||||
height,
|
height,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{
|
||||||
<TbCone
|
width,
|
||||||
size={height * .08}
|
height,
|
||||||
|
fontFamily,
|
||||||
|
icon: <TbCone
|
||||||
|
size={height * .075}
|
||||||
style={{
|
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>
|
</ImageCaption>
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,9 +20,16 @@ export default function PhotoImageResponse({
|
|||||||
fontFamily: string
|
fontFamily: string
|
||||||
isNextImageReady: boolean
|
isNextImageReady: boolean
|
||||||
}) {
|
}) {
|
||||||
const model = photo.model
|
const caption = [
|
||||||
? formatCameraModelTextShort(cameraFromPhoto(photo))
|
photo.model
|
||||||
: undefined;
|
? formatCameraModelTextShort(cameraFromPhoto(photo))
|
||||||
|
: undefined,
|
||||||
|
photo.focalLengthFormatted,
|
||||||
|
photo.fNumberFormatted,
|
||||||
|
photo.isoFormatted,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageContainer {...{ width, height }}>
|
<ImageContainer {...{ width, height }}>
|
||||||
@ -33,24 +40,15 @@ export default function PhotoImageResponse({
|
|||||||
...OG_TEXT_BOTTOM_ALIGNMENT && { imagePosition: 'top' },
|
...OG_TEXT_BOTTOM_ALIGNMENT && { imagePosition: 'top' },
|
||||||
}} />
|
}} />
|
||||||
{shouldShowExifDataForPhoto(photo) &&
|
{shouldShowExifDataForPhoto(photo) &&
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{
|
||||||
{photo.make === 'Apple' &&
|
width,
|
||||||
<div style={{ display: 'flex' }}>
|
height,
|
||||||
<AiFillApple />
|
fontFamily,
|
||||||
</div>}
|
...photo.make === 'Apple' && { icon: <AiFillApple style={{
|
||||||
{model &&
|
marginRight: height * .01,
|
||||||
<div style={{ display: 'flex' }}>
|
}} /> },
|
||||||
{model}
|
}}>
|
||||||
</div>}
|
{caption}
|
||||||
<div style={{ display: 'flex' }}>
|
|
||||||
{photo.focalLengthFormatted}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex' }}>
|
|
||||||
{photo.fNumberFormatted}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{photo.isoFormatted}
|
|
||||||
</div>
|
|
||||||
</ImageCaption>}
|
</ImageCaption>}
|
||||||
</ImageContainer>
|
</ImageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -32,21 +32,29 @@ export default function TagImageResponse({
|
|||||||
height,
|
height,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ImageCaption {...{ width, height, fontFamily }}>
|
<ImageCaption {...{
|
||||||
{isTagFavs(tag)
|
width,
|
||||||
|
height,
|
||||||
|
fontFamily,
|
||||||
|
icon: isTagFavs(tag)
|
||||||
? <FaStar
|
? <FaStar
|
||||||
size={height * .074}
|
size={height * .066}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${height * .01}px)`,
|
transform: `translateY(${height * .0095}px)`,
|
||||||
// Fix horizontal distortion in icon size
|
// Fix horizontal distortion in icon size
|
||||||
width: height * .08,
|
width: height * .076,
|
||||||
|
marginRight: height * .01,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
: <FaTag
|
: <FaTag
|
||||||
size={height * .067}
|
size={height * .06}
|
||||||
style={{ transform: `translateY(${height * .02}px)` }}
|
style={{
|
||||||
/>}
|
transform: `translateY(${height * .016}px)`,
|
||||||
<span>{tag.toUpperCase()}</span>
|
marginRight: height * .015,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
}}>
|
||||||
|
{tag.toLocaleUpperCase()}
|
||||||
</ImageCaption>
|
</ImageCaption>
|
||||||
</ImageContainer>
|
</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({
|
export default function ImageCaption({
|
||||||
height,
|
height,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
subhead,
|
icon,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
fontFamily: string
|
fontFamily: string
|
||||||
subhead?: ReactNode
|
icon?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const paddingEdge = height * .07;
|
||||||
|
const paddingContent = height * .6;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
paddingLeft: height * .0875,
|
paddingLeft: height * .0875,
|
||||||
paddingRight: height * .0875,
|
paddingRight: height * .0875,
|
||||||
color: 'white',
|
color: 'white',
|
||||||
backgroundBlendMode: 'multiply',
|
backgroundBlendMode: 'multiply',
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize: height *.089,
|
fontSize: height *.08,
|
||||||
|
gap: '1rem', // Mimic mono font space metric
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
...OG_TEXT_BOTTOM_ALIGNMENT
|
...OG_TEXT_BOTTOM_ALIGNMENT
|
||||||
? {
|
? {
|
||||||
paddingTop: height * .6,
|
paddingTop: paddingContent,
|
||||||
paddingBottom: height * .075,
|
paddingBottom: paddingEdge,
|
||||||
background: `linear-gradient(to bottom, ${GRADIENT_STOPS})`,
|
background: `linear-gradient(to bottom, ${GRADIENT_STOPS})`,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
paddingTop: height * .075,
|
paddingTop: paddingEdge,
|
||||||
paddingBottom: height * .6,
|
paddingBottom: paddingContent,
|
||||||
background: `linear-gradient(to top, ${GRADIENT_STOPS})`,
|
background: `linear-gradient(to top, ${GRADIENT_STOPS})`,
|
||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
{subhead &&
|
{icon}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: height * .053,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{subhead}
|
|
||||||
</div>}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: height * .053,
|
gap: height * .048,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
|||||||
@ -4,15 +4,20 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
|||||||
import {
|
import {
|
||||||
PATH_ADMIN,
|
PATH_ADMIN,
|
||||||
PATH_ADMIN_PHOTOS,
|
PATH_ADMIN_PHOTOS,
|
||||||
|
PATH_OG,
|
||||||
|
PATH_OG_SAMPLE,
|
||||||
PREFIX_PHOTO,
|
PREFIX_PHOTO,
|
||||||
PREFIX_TAG,
|
PREFIX_TAG,
|
||||||
} from './site/paths';
|
} from './site/paths';
|
||||||
|
|
||||||
export default function middleware(req: NextRequest, res:NextResponse) {
|
export default function middleware(req: NextRequest, res:NextResponse) {
|
||||||
|
console.log('MIDDLEWARE', req.nextUrl.pathname);
|
||||||
const pathname = req.nextUrl.pathname;
|
const pathname = req.nextUrl.pathname;
|
||||||
|
|
||||||
if (pathname === PATH_ADMIN) {
|
if (pathname === PATH_ADMIN) {
|
||||||
return NextResponse.redirect(new URL(PATH_ADMIN_PHOTOS, req.url));
|
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)) {
|
} else if (/^\/photos\/(.)+$/.test(pathname)) {
|
||||||
// Accept /photos/* paths, but serve /p/*
|
// Accept /photos/* paths, but serve /p/*
|
||||||
const matches = pathname.match(/^\/photos\/(.+)$/);
|
const matches = pathname.match(/^\/photos\/(.+)$/);
|
||||||
|
|||||||
@ -14,9 +14,9 @@ export default function PhotoShareModal(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title="Share Photo"
|
|
||||||
pathShare={absolutePathForPhoto(props)}
|
pathShare={absolutePathForPhoto(props)}
|
||||||
pathClose={pathForPhoto(props)}
|
pathClose={pathForPhoto(props)}
|
||||||
|
socialText="Check out this photo"
|
||||||
>
|
>
|
||||||
<PhotoOGTile photo={props.photo} />
|
<PhotoOGTile photo={props.photo} />
|
||||||
</ShareModal>
|
</ShareModal>
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export default function StaggeredOgPhotos({
|
|||||||
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
|
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
|
||||||
onVisible={index === photos.length - 1
|
onVisible={index === photos.length - 1
|
||||||
? onLastPhotoVisible
|
? onLastPhotoVisible
|
||||||
:undefined}
|
: undefined}
|
||||||
riseOnHover
|
riseOnHover
|
||||||
/>)}
|
/>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
generateLocalPostgresString,
|
generateLocalPostgresString,
|
||||||
} from '@/utility/date';
|
} from '@/utility/date';
|
||||||
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
|
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
|
||||||
import { toFixedNumber } from '@/utility/number';
|
import { roundToNumber } from '@/utility/number';
|
||||||
import { convertStringToArray } from '@/utility/string';
|
import { convertStringToArray } from '@/utility/string';
|
||||||
import { generateNanoid } from '@/utility/nanoid';
|
import { generateNanoid } from '@/utility/nanoid';
|
||||||
import {
|
import {
|
||||||
@ -251,7 +251,7 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
// Convert form strings to arrays
|
// Convert form strings to arrays
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
// Convert form strings to numbers
|
// Convert form strings to numbers
|
||||||
aspectRatio: toFixedNumber(parseFloat(photoForm.aspectRatio), 6),
|
aspectRatio: roundToNumber(parseFloat(photoForm.aspectRatio), 6),
|
||||||
focalLength: photoForm.focalLength
|
focalLength: photoForm.focalLength
|
||||||
? parseInt(photoForm.focalLength)
|
? parseInt(photoForm.focalLength)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
import { Photo, PhotoDateRange } from '../photo';
|
import { Photo, PhotoDateRange } from '../photo';
|
||||||
import ShareModal from '@/components/ShareModal';
|
import ShareModal from '@/components/ShareModal';
|
||||||
import FilmSimulationOGTile from './FilmSimulationOGTile';
|
import FilmSimulationOGTile from './FilmSimulationOGTile';
|
||||||
import { FilmSimulation } from '.';
|
import { FilmSimulation, shareTextForFilmSimulation } from '.';
|
||||||
|
|
||||||
export default function FilmSimulationShareModal({
|
export default function FilmSimulationShareModal({
|
||||||
simulation,
|
simulation,
|
||||||
@ -20,9 +20,9 @@ export default function FilmSimulationShareModal({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title="Share Photos"
|
|
||||||
pathShare={absolutePathForFilmSimulation(simulation)}
|
pathShare={absolutePathForFilmSimulation(simulation)}
|
||||||
pathClose={pathForFilmSimulation(simulation)}
|
pathClose={pathForFilmSimulation(simulation)}
|
||||||
|
socialText={shareTextForFilmSimulation(simulation)}
|
||||||
>
|
>
|
||||||
<FilmSimulationOGTile {...{ simulation, photos, count, dateRange }} />
|
<FilmSimulationOGTile {...{ simulation, photos, count, dateRange }} />
|
||||||
</ShareModal>
|
</ShareModal>
|
||||||
|
|||||||
@ -40,6 +40,11 @@ export const titleForFilmSimulation = (
|
|||||||
photoQuantityText(explicitCount ?? photos.length),
|
photoQuantityText(explicitCount ?? photos.length),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
export const shareTextForFilmSimulation = (
|
||||||
|
simulation: FilmSimulation,
|
||||||
|
) =>
|
||||||
|
`Photos shot on Fujifilm ${labelForFilmSimulation(simulation).large}`;
|
||||||
|
|
||||||
export const descriptionForFilmSimulationPhotos = (
|
export const descriptionForFilmSimulationPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export default function SiteChecklistClient({
|
|||||||
hasTitle,
|
hasTitle,
|
||||||
hasDomain,
|
hasDomain,
|
||||||
showRepoLink,
|
showRepoLink,
|
||||||
|
showSocial,
|
||||||
showFilmSimulations,
|
showFilmSimulations,
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
isProModeEnabled,
|
isProModeEnabled,
|
||||||
@ -438,6 +439,17 @@ export default function SiteChecklistClient({
|
|||||||
Set environment variable to {'"1"'} to hide footer link:
|
Set environment variable to {'"1"'} to hide footer link:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_REPO_LINK'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Show Fujifilm simulations"
|
title="Show Fujifilm simulations"
|
||||||
status={showFilmSimulations}
|
status={showFilmSimulations}
|
||||||
|
|||||||
@ -130,6 +130,8 @@ export const PUBLIC_API_ENABLED =
|
|||||||
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
process.env.NEXT_PUBLIC_PUBLIC_API === '1';
|
||||||
export const SHOW_REPO_LINK =
|
export const SHOW_REPO_LINK =
|
||||||
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
|
||||||
|
export const SHOW_SOCIAL =
|
||||||
|
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
|
||||||
export const SHOW_FILM_SIMULATIONS =
|
export const SHOW_FILM_SIMULATIONS =
|
||||||
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
|
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
|
||||||
export const SHOW_EXIF_DATA =
|
export const SHOW_EXIF_DATA =
|
||||||
@ -170,6 +172,7 @@ export const CONFIG_CHECKLIST_STATUS = {
|
|||||||
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
|
hasTitle: (process.env.NEXT_PUBLIC_SITE_TITLE ?? '').length > 0,
|
||||||
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
|
hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0,
|
||||||
showRepoLink: SHOW_REPO_LINK,
|
showRepoLink: SHOW_REPO_LINK,
|
||||||
|
showSocial: SHOW_SOCIAL,
|
||||||
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
showFilmSimulations: SHOW_FILM_SIMULATIONS,
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
isProModeEnabled: PRO_MODE_ENABLED,
|
isProModeEnabled: PRO_MODE_ENABLED,
|
||||||
|
|||||||
@ -6,26 +6,27 @@ import { parameterize } from '@/utility/string';
|
|||||||
import { TAG_HIDDEN } from '@/tag';
|
import { TAG_HIDDEN } from '@/tag';
|
||||||
|
|
||||||
// Core paths
|
// Core paths
|
||||||
export const PATH_ROOT = '/';
|
export const PATH_ROOT = '/';
|
||||||
export const PATH_GRID = '/grid';
|
export const PATH_GRID = '/grid';
|
||||||
export const PATH_ADMIN = '/admin';
|
export const PATH_ADMIN = '/admin';
|
||||||
export const PATH_API = '/api';
|
export const PATH_API = '/api';
|
||||||
export const PATH_SIGN_IN = '/sign-in';
|
export const PATH_SIGN_IN = '/sign-in';
|
||||||
export const PATH_OG = '/og';
|
export const PATH_OG = '/og';
|
||||||
|
|
||||||
// Path prefixes
|
// Path prefixes
|
||||||
export const PREFIX_PHOTO = '/p';
|
export const PREFIX_PHOTO = '/p';
|
||||||
export const PREFIX_TAG = '/tag';
|
export const PREFIX_TAG = '/tag';
|
||||||
export const PREFIX_CAMERA = '/shot-on';
|
export const PREFIX_CAMERA = '/shot-on';
|
||||||
export const PREFIX_FILM_SIMULATION = '/film';
|
export const PREFIX_FILM_SIMULATION = '/film';
|
||||||
export const PREFIX_FOCAL_LENGTH = '/focal';
|
export const PREFIX_FOCAL_LENGTH = '/focal';
|
||||||
|
|
||||||
// Dynamic paths
|
// Dynamic paths
|
||||||
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
|
||||||
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
|
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
|
||||||
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
|
||||||
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
|
// eslint-disable-next-line max-len
|
||||||
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
|
||||||
|
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
|
||||||
|
|
||||||
// Admin paths
|
// Admin paths
|
||||||
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
|
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_CONFIGURATION = `${PATH_ADMIN}/configuration`;
|
||||||
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
|
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
|
// API paths
|
||||||
export const PATH_API_STORAGE = `${PATH_API}/storage`;
|
export const PATH_API_STORAGE = `${PATH_API}/storage`;
|
||||||
export const PATH_API_VERCEL_BLOB_UPLOAD = `${PATH_API_STORAGE}/vercel-blob`;
|
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) =>
|
export const isPathProtected = (pathname?: string) =>
|
||||||
checkPathPrefix(pathname, PATH_ADMIN) ||
|
checkPathPrefix(pathname, PATH_ADMIN) ||
|
||||||
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||
|
checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)) ||
|
||||||
pathname === PATH_OG;
|
checkPathPrefix(pathname, PATH_OG);
|
||||||
|
|
||||||
export const getPathComponents = (pathname = ''): {
|
export const getPathComponents = (pathname = ''): {
|
||||||
photoId?: string
|
photoId?: string
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { absolutePathForTag, pathForTag } from '@/site/paths';
|
|||||||
import { Photo, PhotoDateRange } from '../photo';
|
import { Photo, PhotoDateRange } from '../photo';
|
||||||
import ShareModal from '@/components/ShareModal';
|
import ShareModal from '@/components/ShareModal';
|
||||||
import TagOGTile from './TagOGTile';
|
import TagOGTile from './TagOGTile';
|
||||||
|
import { shareTextForTag } from '.';
|
||||||
|
|
||||||
export default function TagShareModal({
|
export default function TagShareModal({
|
||||||
tag,
|
tag,
|
||||||
@ -16,9 +17,9 @@ export default function TagShareModal({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
title="Share Photos"
|
|
||||||
pathShare={absolutePathForTag(tag)}
|
pathShare={absolutePathForTag(tag)}
|
||||||
pathClose={pathForTag(tag)}
|
pathClose={pathForTag(tag)}
|
||||||
|
socialText={shareTextForTag(tag)}
|
||||||
>
|
>
|
||||||
<TagOGTile {...{ tag, photos, count, dateRange }} />
|
<TagOGTile {...{ tag, photos, count, dateRange }} />
|
||||||
</ShareModal>
|
</ShareModal>
|
||||||
|
|||||||
@ -11,9 +11,9 @@ import {
|
|||||||
} from '@/site/paths';
|
} from '@/site/paths';
|
||||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
||||||
|
|
||||||
// Reserved/virtual tags
|
// Reserved tags
|
||||||
export const TAG_FAVS = 'favs'; // Reserved
|
export const TAG_FAVS = 'favs';
|
||||||
export const TAG_HIDDEN = 'hidden'; // Virtual
|
export const TAG_HIDDEN = 'hidden';
|
||||||
|
|
||||||
export type TagsWithMeta = {
|
export type TagsWithMeta = {
|
||||||
tag: string
|
tag: string
|
||||||
@ -24,10 +24,7 @@ export const formatTag = (tag?: string) =>
|
|||||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||||
|
|
||||||
export const doesStringContainReservedTags = (tags?: string) =>
|
export const doesStringContainReservedTags = (tags?: string) =>
|
||||||
convertStringToArray(tags)?.some(tag => (
|
convertStringToArray(tags)?.some(tag => isTagFavs(tag) || isTagHidden(tag));
|
||||||
isTagFavs(tag) ||
|
|
||||||
tag.toLowerCase() === TAG_HIDDEN
|
|
||||||
));
|
|
||||||
|
|
||||||
export const titleForTag = (
|
export const titleForTag = (
|
||||||
tag: string,
|
tag: string,
|
||||||
@ -38,6 +35,9 @@ export const titleForTag = (
|
|||||||
photoQuantityText(explicitCount ?? photos.length),
|
photoQuantityText(explicitCount ?? photos.length),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
export const shareTextForTag = (tag: string) =>
|
||||||
|
isTagFavs(tag) ? 'Favorite photos' : `Photos tagged '${tag}'`;
|
||||||
|
|
||||||
export const sortTags = (
|
export const sortTags = (
|
||||||
tags: string[],
|
tags: string[],
|
||||||
tagToHide?: string,
|
tagToHide?: string,
|
||||||
@ -92,6 +92,8 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
|||||||
export const isPathFavs = (pathname?: string) =>
|
export const isPathFavs = (pathname?: string) =>
|
||||||
getPathComponents(pathname).tag === TAG_FAVS;
|
getPathComponents(pathname).tag === TAG_FAVS;
|
||||||
|
|
||||||
|
export const isTagHidden = (tag: string) => tag.toLowerCase() === TAG_HIDDEN;
|
||||||
|
|
||||||
export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
||||||
if (hiddenPhotosCount > 0) {
|
if (hiddenPhotosCount > 0) {
|
||||||
return tags
|
return tags
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { OrientationTypes, type ExifData } from 'ts-exif-parser';
|
import { OrientationTypes, type ExifData } from 'ts-exif-parser';
|
||||||
import { formatNumberToFraction } from './number';
|
import { formatNumberToFraction, roundToString } from './number';
|
||||||
|
|
||||||
const OFFSET_REGEX = /[+-]\d\d:\d\d/;
|
const OFFSET_REGEX = /[+-]\d\d:\d\d/;
|
||||||
|
|
||||||
@ -32,10 +32,12 @@ export const getAspectRatioFromExif = (data: ExifData): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const formatAperture = (aperture?: number) =>
|
export const formatAperture = (aperture?: number) =>
|
||||||
aperture ? `ƒ/${aperture}` : undefined;
|
aperture
|
||||||
|
? `ƒ/${roundToString(aperture)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export const formatIso = (iso?: number) =>
|
export const formatIso = (iso?: number) =>
|
||||||
iso ? `ISO ${iso}` : undefined;
|
iso ? `ISO ${iso.toLocaleString()}` : undefined;
|
||||||
|
|
||||||
export const formatExposureTime = (exposureTime = 0) =>
|
export const formatExposureTime = (exposureTime = 0) =>
|
||||||
exposureTime > 0
|
exposureTime > 0
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
export const toFixedNumber = (
|
export const roundToString = (
|
||||||
number: number,
|
number: number,
|
||||||
digits: number,
|
place = 1,
|
||||||
base = 10) => {
|
includeZero?: boolean,
|
||||||
const pow = Math.pow(base ?? 10, digits);
|
) => {
|
||||||
return Math.round(number * pow) / pow;
|
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 => {
|
const gcd = (a: number, b: number): number => {
|
||||||
if (b <= 0.0000001) {
|
if (b <= 0.0000001) {
|
||||||
return a;
|
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