Add initial iPhone lens text formatting support

This commit is contained in:
Sam Becker 2025-03-16 21:28:12 -05:00
parent f64349786b
commit c3b3fe4367
14 changed files with 132 additions and 106 deletions

View File

@ -2,15 +2,15 @@
import { formatLensText, Lens } from '@/lens';
const IPHONE_15_PRO_FRONT: Lens = { make: 'Apple', model: 'iPhone 15 Pro front TrueDepth camera 2.69mm f/1.9' };
const IPHONE_15_PRO_BACK_WIDE: Lens = { make: 'Apple', model: 'iPhone 15 Pro front TrueDepth camera 2.69mm f/1.9' };
const IPHONE_15_PRO_BACK_MAIN: Lens = { make: 'Apple', model: 'iPhone 15 Pro front TrueDepth camera 2.69mm f/1.9' };
const IPHONE_15_PRO_BACK_TELEPHOTO: Lens = { make: 'Apple', model: 'iPhone 15 Pro front TrueDepth camera 2.69mm f/1.9' };
const IPHONE_15_PRO_BACK_WIDE: Lens = { make: 'Apple', model: 'iPhone 15 Pro back triple camera 6.765mm f/2.2' };
const IPHONE_15_PRO_BACK_MAIN: Lens = { make: 'Apple', model: 'iPhone 15 Pro back triple camera 6.765mm f/1.78' };
const IPHONE_15_PRO_BACK_TELEPHOTO: Lens = { make: 'Apple', model: 'iPhone 15 Pro back triple camera 6.765mm f/2.8' };
describe('Lens', () => {
it('correctly formats iPhone lenses', () => {
expect(formatLensText(IPHONE_15_PRO_FRONT)).toBe('Front Camera');
expect(formatLensText(IPHONE_15_PRO_BACK_WIDE)).toBe('Wide Camera');
expect(formatLensText(IPHONE_15_PRO_BACK_MAIN)).toBe('Main Camera');
expect(formatLensText(IPHONE_15_PRO_BACK_TELEPHOTO)).toBe('Telephoto Camera');
expect(formatLensText(IPHONE_15_PRO_FRONT)).toBe('15 Pro front');
expect(formatLensText(IPHONE_15_PRO_BACK_WIDE)).toBe('15 Pro Wide (6.765mm)');
expect(formatLensText(IPHONE_15_PRO_BACK_MAIN)).toBe('15 Pro Main (6.765mm)');
expect(formatLensText(IPHONE_15_PRO_BACK_TELEPHOTO)).toBe('15 Pro Telephoto (6.765mm)');
});
});

View File

@ -161,7 +161,8 @@ export default function AdminAppMenu({
header={<div className="flex items-center select-none">
<IconLock
size={15}
className="inline-block w-5 mr-2 translate-x-[1px]"
className="inline-block w-5 mr-2"
narrow
/>
<span className="grow">Admin menu</span>
</div>}

View File

@ -113,9 +113,9 @@ const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
export const pathForPhoto = ({
photo,
tag,
camera,
lens,
tag,
simulation,
focal,
recipe,

View File

@ -37,12 +37,12 @@ import { useDebounce } from 'use-debounce';
import Spinner from '../Spinner';
import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState';
import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi';
import { signOutAction } from '@/auth/actions';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
@ -69,6 +69,7 @@ import IconPhoto from '../icons/IconPhoto';
import IconRecipe from '../icons/IconRecipe';
import IconFocalLength from '../icons/IconFocalLength';
import IconFilmSimulation from '../icons/IconFilmSimulation';
import IconLock from '../icons/IconLock';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -424,20 +425,20 @@ export default function CommandKClient({
if (isUserSignedIn) {
adminSection.items.push({
label: 'Manage Photos',
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: PATH_ADMIN_PHOTOS,
});
if (uploadsCount) {
adminSection.items.push({
label: 'Manage Uploads',
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: PATH_ADMIN_UPLOADS,
});
}
if (tagsCount) {
adminSection.items.push({
label: 'Manage Tags',
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: PATH_ADMIN_TAGS,
});
}
@ -448,17 +449,17 @@ export default function CommandKClient({
<InsightsIndicatorDot />}
</span>,
keywords: ['app insights'],
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: PATH_ADMIN_INSIGHTS,
}, {
label: 'App Config',
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: PATH_ADMIN_CONFIGURATION,
}, {
label: selectedPhotoIds === undefined
? 'Select Multiple Photos'
: 'Exit Select Multiple Photos',
annotation: <BiLockAlt />,
annotation: <IconLock narrow />,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
: undefined,

View File

@ -1,6 +1,12 @@
import { IconBaseProps } from 'react-icons';
import { BiLockAlt } from 'react-icons/bi';
import { FiLock } from 'react-icons/fi';
export default function IconLock(props: IconBaseProps) {
return <FiLock {...props} />;
export default function IconLock({
narrow,
...props
}: IconBaseProps & { narrow?: boolean }) {
return narrow
? <BiLockAlt {...props} />
: <FiLock {...props} />;
}

View File

@ -31,6 +31,7 @@ export default function EntityLink({
truncate = true,
className,
classNameIcon,
uppercase,
debug,
}: {
icon: ReactNode
@ -44,6 +45,7 @@ export default function EntityLink({
truncate?: boolean
className?: string
classNameIcon?: string
uppercase?: boolean
debug?: boolean
} & EntityLinkExternalProps) {
const classForContrast = () => {
@ -81,6 +83,7 @@ export default function EntityLink({
prefetch,
title,
type,
uppercase,
className: clsx(
classForContrast(),
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',

View File

@ -15,6 +15,7 @@ export default function LabeledIcon({
classNameIcon,
children,
iconWide,
uppercase = true,
debug,
}: {
icon?: ReactNode,
@ -23,6 +24,7 @@ export default function LabeledIcon({
classNameIcon?: string,
children: ReactNode,
iconWide?:boolean,
uppercase?: boolean,
debug?: boolean,
}) {
return (
@ -44,7 +46,8 @@ export default function LabeledIcon({
</Icon>}
{children && type !== 'icon-only' &&
<span className={clsx(
'uppercase overflow-hidden',
'overflow-hidden',
uppercase && 'uppercase',
debug && 'bg-gray-300 dark:bg-gray-700',
)}>
{children}

View File

@ -13,13 +13,15 @@ export default function PhotoLens({
prefetch,
countOnHover,
className,
shortText,
}: {
lens: Lens
countOnHover?: number
shortText?: boolean
} & EntityLinkExternalProps) {
return (
<EntityLink
label={formatLensText(lens, 'short')}
label={formatLensText(lens, shortText ? 'short' : 'medium')}
href={pathForLens(lens)}
icon={<IconLens
size={14}

71
src/lens/apple.ts Normal file
View File

@ -0,0 +1,71 @@
/* eslint-disable max-len */
import { Lens } from '.';
const LENS_MAKE_APPLE = 'apple';
export const isLensMakeApple = (make?: string) =>
make?.toLocaleLowerCase() === LENS_MAKE_APPLE;
export const isLensApple = ({ make }: Lens) =>
isLensMakeApple(make);
export const formatAppleLensText = (
model: string,
includePhoneName?: boolean,
) => {
const [
_,
phoneName,
side,
focalLength,
aperture,
] = (/iPhone ([0-9a-z]{1,3}(?: (?:Pro|Max))*).*?(back|front).*?([0-9\.]+)mm.*?f\/([0-9\.]+)/gi.exec(model) ?? []);
const format = (lensName: string, includeFocalLength = true) => {
let result = '';
if (includePhoneName) {
result += `${phoneName} `;
}
result += lensName;
if (!includePhoneName) {
result += ' Camera';
}
if (includeFocalLength && focalLength) {
result += ` (${focalLength}mm)`;
}
return result;
};
if (side === 'front') {
return format('front', false);
} else if (side === 'back') {
switch (phoneName) {
case '13 Pro':
switch (aperture) {
case '1.8': return format('Wide');
case '1.5': return format('Main');
case '2.8': return format('Telephoto');
}
case '14 Pro':
switch (aperture) {
case '2.2': return format('Wide');
case '1.78': return format('Main');
case '2.8': return format('Telephoto');
}
case '15 Pro':
switch (aperture) {
case '2.2': return format('Wide');
case '1.78': return format('Main');
case '2.8': return format('Telephoto');
}
case '16 Pro':
switch (aperture) {
case '2.2': return format('Wide');
case '1.78': return format('Main');
case '2.8': return format('Telephoto');
}
}
}
return model;
};

View File

@ -1,10 +1,9 @@
import { Photo } from '@/photo';
import { parameterize } from '@/utility/string';
import { formatAppleLensText, isLensMakeApple } from './apple';
const LENS_PLACEHOLDER: Lens = { make: 'Lens', model: 'Model' };
const LENS_MAKE_APPLE = 'apple';
export type Lens = {
make: string
model: string
@ -58,41 +57,13 @@ export const lensFromPhoto = (
? { make: photo.lensMake, model: photo.lensModel }
: fallback ?? LENS_PLACEHOLDER;
const isLensMakeApple = (make?: string) =>
make?.toLocaleLowerCase() === LENS_MAKE_APPLE;
export const isLensApple = ({ make }: Lens) =>
isLensMakeApple(make);
const formatAppleLensText = (
model: string,
includePhoneName?: boolean,
) => {
if (model.includes('15 Pro')) {
const phoneName = '15 Pro';
if (model.includes('front')) { return includePhoneName
? `${phoneName}: Front Camera`
: 'Front Camera'; }
if (model.includes('f/2.2')) { return includePhoneName
? `${phoneName}: Wide Camera`
: 'Wide Camera'; }
if (model.includes('f/1.78')) { return includePhoneName
? `${phoneName}: Main Camera`
: 'Main Camera'; }
if (model.includes('f/2.8')) { return includePhoneName
? `${phoneName}: Telephoto Camera`
: 'Telephoto Camera'; }
}
return model;
};
export const formatLensText = (
{ make, model: modelRaw }: Lens,
length:
'long' | // Unmodified make and model
'medium' | // Make and model, with modifiers removed
'short' // Model only
= 'short',
= 'medium',
) => {
// Capture simple make without modifiers like 'Corporation' or 'Company'
const makeSimple = make.match(/^(\S+)/)?.[1];

View File

@ -19,7 +19,7 @@ export const titleForLens = (
photos: Photo[],
explicitCount?: number,
) => [
'Shot on',
'Lens:',
formatLensText(lensFromPhoto(photos[0], lens)),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
@ -29,7 +29,7 @@ export const shareTextForLens = (
photos: Photo[],
) =>
[
'Photos shot on',
'Lens:',
formatLensText(lensFromPhoto(photos[0], lens)),
].join(' ');

View File

@ -295,6 +295,7 @@ export default function PhotoLarge({
lens={lens}
contrast="medium"
prefetch={prefetchRelatedLinks}
shortText
/>}
</div>}
{showRecipeContent && recipeTitle &&

View File

@ -11,16 +11,12 @@ import { clsx } from 'clsx/lite';
export default function PhotoLink({
photo,
tag,
camera,
simulation,
focal,
recipe,
scroll,
prefetch,
nextPhotoAnimation,
className,
children,
...categories
}: {
photo?: Photo
scroll?: boolean
@ -34,7 +30,7 @@ export default function PhotoLink({
return (
photo
? <Link
href={pathForPhoto({ photo, tag, camera, simulation, focal, recipe })}
href={pathForPhoto({ photo, ...categories })}
prefetch={prefetch}
onClick={() => {
if (nextPhotoAnimation) {

View File

@ -24,11 +24,7 @@ export default function PhotoPrevNext({
photo,
photos = [],
className,
tag,
camera,
simulation,
focal,
recipe,
...categories
}: {
photo?: Photo
photos?: Photo[]
@ -44,42 +40,30 @@ export default function PhotoPrevNext({
const previousPhoto = photo ? getPreviousPhoto(photo, photos) : undefined;
const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
const pathPrevious = previousPhoto
? pathForPhoto({ photo: previousPhoto, ...categories })
: undefined;
const pathNext = nextPhoto
? pathForPhoto({ photo: nextPhoto, ...categories })
: undefined;
useEffect(() => {
if (shouldRespondToKeyboardCommands) {
const onKeyUp = (e: KeyboardEvent) => {
switch (e.key.toUpperCase()) {
case 'ARROWLEFT':
case 'J':
if (previousPhoto) {
if (pathPrevious) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
router.push(
pathForPhoto({
photo: previousPhoto,
tag,
camera,
simulation,
focal,
recipe,
}),
{ scroll: false },
);
router.push(pathPrevious, { scroll: false });
}
break;
case 'ARROWRIGHT':
case 'L':
if (nextPhoto) {
if (pathNext) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
router.push(
pathForPhoto({
photo: nextPhoto,
tag,
camera,
simulation,
focal,
recipe,
}),
{ scroll: false },
);
router.push(pathNext, { scroll: false });
}
break;
};
@ -91,13 +75,8 @@ export default function PhotoPrevNext({
router,
shouldRespondToKeyboardCommands,
setNextPhotoAnimation,
previousPhoto,
nextPhoto,
tag,
camera,
simulation,
focal,
recipe,
pathPrevious,
pathNext,
]);
return (
@ -111,14 +90,10 @@ export default function PhotoPrevNext({
'items-center sm:items-start',
)}>
<PhotoLink
{...categories}
photo={previousPhoto}
className="select-none h-[1rem]"
nextPhotoAnimation={ANIMATION_RIGHT}
tag={tag}
camera={camera}
simulation={simulation}
focal={focal}
recipe={recipe}
scroll={false}
prefetch
>
@ -131,14 +106,10 @@ export default function PhotoPrevNext({
/
</span>
<PhotoLink
{...categories}
photo={nextPhoto}
className="select-none h-[1rem]"
nextPhotoAnimation={ANIMATION_LEFT}
tag={tag}
camera={camera}
simulation={simulation}
focal={focal}
recipe={recipe}
scroll={false}
prefetch
>