Refine og image layouts, add X posting to share modal

This commit is contained in:
Sam Becker 2024-05-25 23:51:22 -05:00
parent 8992f3455b
commit 567d59bf0e
31 changed files with 325 additions and 148 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -7,21 +7,46 @@ 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">
{title &&
<div className={clsx(
'flex items-center gap-x-3',
'text-2xl leading-snug',
@ -30,8 +55,9 @@ export default function ShareModal({
<div className="flex-grow">
{title}
</div>
</div>
</div>}
{children}
<div className="flex items-center gap-2">
<div className={clsx(
'rounded-md',
'w-full overflow-hidden',
@ -41,21 +67,23 @@ export default function ShareModal({
<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',
)}
onClick={() => {
{renderIcon(
<BiCopy size={18} />,
() => {
navigator.clipboard.writeText(pathShare);
toastSuccess('Link to photo copied');
}}
>
<BiCopy size={18} />
},
true,
)}
</div>
{SHOW_SOCIAL &&
renderIcon(
<PiXLogo size={18} />,
() => window.open(
generateXPostText(pathShare, socialText),
'_blank',
),
)}
</div>
</div>
</Modal>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,9 +20,16 @@ export default function PhotoImageResponse({
fontFamily: string
isNextImageReady: boolean
}) {
const model = photo.model
const caption = [
photo.model
? formatCameraModelTextShort(cameraFromPhoto(photo))
: undefined;
: 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>
);

View File

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

View File

@ -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 &&
{icon}
<div
style={{
display: 'flex',
gap: height * .053,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{subhead}
</div>}
<div
style={{
display: 'flex',
gap: height * .053,
gap: height * .048,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',

View File

@ -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\/(.+)$/);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ export const 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]`;
@ -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

View File

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

View File

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

View File

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

View File

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