Add initial iPhone lens text formatting support
This commit is contained in:
parent
f64349786b
commit
c3b3fe4367
@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -113,9 +113,9 @@ const getPhotoId = (photoOrPhotoId: PhotoOrPhotoId) =>
|
||||
|
||||
export const pathForPhoto = ({
|
||||
photo,
|
||||
tag,
|
||||
camera,
|
||||
lens,
|
||||
tag,
|
||||
simulation,
|
||||
focal,
|
||||
recipe,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
71
src/lens/apple.ts
Normal 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;
|
||||
};
|
||||
@ -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];
|
||||
|
||||
@ -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(' ');
|
||||
|
||||
|
||||
@ -295,6 +295,7 @@ export default function PhotoLarge({
|
||||
lens={lens}
|
||||
contrast="medium"
|
||||
prefetch={prefetchRelatedLinks}
|
||||
shortText
|
||||
/>}
|
||||
</div>}
|
||||
{showRecipeContent && recipeTitle &&
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user