Create device photo set view

This commit is contained in:
Sam Becker 2023-10-02 11:51:04 -05:00
parent 6e68aa16c5
commit af7af53401
12 changed files with 247 additions and 79 deletions

View File

@ -0,0 +1,71 @@
import { getPhotosCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import DeviceHeader from '@/device/DeviceHeader';
import { getMakeModelFromDevice } from '@/device';
import PhotoGrid from '@/photo/PhotoGrid';
import { getUniqueDevices } from '@/services/postgres';
import { generateMetaForTag } from '@/tag';
import { Metadata } from 'next';
interface TagProps {
params: { device: string }
}
export async function generateStaticParams() {
const devices = await getUniqueDevices();
return devices.map(device => ({
params: { device },
}));
}
export async function generateMetadata({
params: { device },
}: TagProps): Promise<Metadata> {
const photos = await getPhotosCached({
device: getMakeModelFromDevice(device),
});
const {
url,
title,
description,
images,
} = generateMetaForTag(device, photos);
return {
title,
openGraph: {
title,
description,
images,
url,
},
twitter: {
images,
description,
card: 'summary_large_image',
},
description,
};
}
export default async function DevicePage({ params: { device } }:TagProps) {
const photos = await getPhotosCached({
device: getMakeModelFromDevice(device),
});
// Harvest original make/model with proper spaces/slashes
const deviceFormatted = photos.length > 0
? `${photos[0].make} ${photos[0].model}`
: device;
return (
<SiteGrid
key="Device Grid"
contentMain={<div className="space-y-8 mt-4">
<DeviceHeader device={deviceFormatted} photos={photos} />
<PhotoGrid photos={photos} tag={deviceFormatted} />
</div>}
/>
);
}

View File

@ -8,7 +8,7 @@ import HeaderList from '@/components/HeaderList';
import MorePhotos from '@/components/MorePhotos';
import SiteGrid from '@/components/SiteGrid';
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
import PhotoDevice from '@/photo/PhotoDevice';
import PhotoDevice from '@/device/PhotoDevice';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotosEmptyState from '@/photo/PhotosEmptyState';
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
@ -69,6 +69,7 @@ export default async function GridPage({
key={device}
make={make}
model={model}
showIcon={false}
hideApple
/>)}
/>}

View File

@ -0,0 +1,26 @@
import { Photo } from '@/photo';
import { pathForTagShare } from '@/site/paths';
import PhotoHeader from '@/photo/PhotoHeader';
import { descriptionForDevicePhotos, getMakeModelFromDevice } from '.';
import PhotoDevice from './PhotoDevice';
export default function DeviceHeader({
device,
photos,
selectedPhoto,
}: {
device: string
photos: Photo[]
selectedPhoto?: Photo
}) {
return (
<PhotoHeader
entity={<PhotoDevice {...getMakeModelFromDevice(device)} />}
entityVerb="Device"
entityDescription={descriptionForDevicePhotos(photos)}
photos={photos}
selectedPhoto={selectedPhoto}
sharePath={pathForTagShare(device)}
/>
);
}

View File

@ -0,0 +1,45 @@
import { AiFillApple } from 'react-icons/ai';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { pathForDevice } from '@/site/paths';
import { IoMdCamera } from 'react-icons/io';
export default function PhotoDevice({
make,
model,
showIcon = true,
hideApple = true,
}: {
make?: string
model?: string
showIcon?: boolean
hideApple?: boolean
}) {
console.log({ make, model, showIcon, hideApple });
return (
<Link
href={pathForDevice(make, model)}
className={cc(
'inline-flex items-center self-start',
'uppercase',
)}
>
{showIcon && <>
<IoMdCamera size={13} />
&nbsp;
</>}
{!(hideApple && make?.toLowerCase() === 'apple') &&
<>
{make?.toLowerCase() === 'apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
size={14}
/>
: make}
&nbsp;
</>}
{model}
</Link>
);
}

14
src/device/index.ts Normal file
View File

@ -0,0 +1,14 @@
import { Photo, descriptionForPhotoSet } from '@/photo';
// Assumes no device makes ('Fujifilm,' 'Apple,' 'Canon', etc.)
// will have dashes in them
export const getMakeModelFromDevice = (device: string) => {
const [make, model] = device.toLowerCase().split(/[-| ](.*)/s);
return { make, model };
};
export const descriptionForDevicePhotos = (
photos: Photo[],
dateBased?: boolean,
) =>
descriptionForPhotoSet(photos, 'device', dateBased);

View File

@ -1,32 +0,0 @@
import { AiFillApple } from 'react-icons/ai';
import { cc } from '@/utility/css';
export default function PhotoDevice({
make,
model,
hideApple,
}: {
make?: string
model?: string
hideApple?: boolean
}) {
return (
<div className={cc(
'inline-flex items-center self-start',
'uppercase',
)}>
{!(hideApple && make === 'Apple') &&
<>
{make === 'Apple'
? <AiFillApple
title="Apple"
className="translate-y-[-0.5px]"
size={14}
/>
: make}
&nbsp;
</>}
{model}
</div>
);
}

54
src/photo/PhotoHeader.tsx Normal file
View File

@ -0,0 +1,54 @@
import { cc } from '@/utility/css';
import { Photo, dateRangeForPhotos } from '.';
import ShareButton from '@/components/ShareButton';
export default function PhotoHeader({
entity,
entityVerb,
entityDescription,
photos,
selectedPhoto,
sharePath,
}: {
entity: JSX.Element
entityVerb: string
entityDescription: string
photos: Photo[]
selectedPhoto?: Photo
sharePath: string
}) {
const { start, end } = dateRangeForPhotos(photos);
const selectedPhotoIndex = selectedPhoto
? photos.findIndex(photo => photo.id === selectedPhoto.id)
: undefined;
return (
<div className={cc(
'flex flex-col gap-y-0.5',
'xs:grid grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
)}>
{entity}
<span className={cc(
'inline-flex gap-2 items-center self-start',
'uppercase text-gray-400 dark:text-gray-500',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{selectedPhotoIndex !== undefined
? `${entityVerb} ${selectedPhotoIndex + 1} of ${photos.length}`
: entityDescription}
{selectedPhotoIndex === undefined &&
<ShareButton path={sharePath} dim />}
</span>
<span className={cc(
'hidden sm:inline-block',
'text-right uppercase',
'text-gray-400 dark:text-gray-500',
)}>
{start === end
? start
: <>{start}<br /> {end}</>}
</span>
</div>
);
}

View File

@ -6,7 +6,7 @@ import Link from 'next/link';
import { pathForPhoto, pathForPhotoShare } from '@/site/paths';
import PhotoTags from '@/tag/PhotoTags';
import ShareButton from '@/components/ShareButton';
import PhotoDevice from './PhotoDevice';
import PhotoDevice from '../device/PhotoDevice';
export default function PhotoLarge({
photo,
@ -64,7 +64,12 @@ export default function PhotoLarge({
{tagsToShow.length > 0 &&
<PhotoTags tags={tagsToShow} />}
</div>
<PhotoDevice make={photo.make} model={photo.model} />
<PhotoDevice
make={photo.make}
model={photo.model}
showIcon={false}
hideApple={false}
/>
</>)}
{renderMiniGrid(<>
<ul className={cc(

View File

@ -169,6 +169,18 @@ export const translatePhotoId = (id: string) =>
export const titleForPhoto = (photo: Photo) =>
photo.title || 'Untitled';
export const labelForPhotos = (photos: Photo[]) =>
photos.length === 1 ? 'Photo' : 'Photos';
export const descriptionForPhotoSet = (
photos:Photo[],
descriptor: string,
dateBased?: boolean,
) =>
dateBased
? dateRangeForPhotos(photos).description.toUpperCase()
: `${photos.length} ${descriptor} ${labelForPhotos(photos)}`;
export const dateRangeForPhotos = (photos: Photo[]) => {
const start = photos[0].takenAtNaiveFormattedShort;
const end = photos[photos.length - 1].takenAtNaiveFormattedShort;

View File

@ -194,7 +194,9 @@ const sqlGetPhotosByDevice = async (
model: string,
) => sql<PhotoDb>`
SELECT * FROM photos
WHERE make=${make} AND model=${model}
WHERE
LOWER(make)=${make} AND
LOWER(REPLACE(model, ' ', '-'))=${model}
ORDER BY taken_at DESC
LIMIT ${limit}
`;

View File

@ -1,9 +1,8 @@
import { Photo, dateRangeForPhotos } from '@/photo';
import { cc } from '@/utility/css';
import { Photo } from '@/photo';
import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos } from '.';
import ShareButton from '@/components/ShareButton';
import { pathForTagShare } from '@/site/paths';
import PhotoHeader from '@/photo/PhotoHeader';
export default function TagHeader({
tag,
@ -14,38 +13,14 @@ export default function TagHeader({
photos: Photo[]
selectedPhoto?: Photo
}) {
const { start, end } = dateRangeForPhotos(photos);
const selectedPhotoIndex = selectedPhoto
? photos.findIndex(photo => photo.id === selectedPhoto.id)
: undefined;
return (
<div className={cc(
'flex flex-col gap-y-0.5',
'xs:grid grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
)}>
<PhotoTag tag={tag} />
<span className={cc(
'inline-flex gap-2 items-center self-start',
'uppercase text-gray-400 dark:text-gray-500',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{selectedPhotoIndex !== undefined
? `Tagged ${selectedPhotoIndex + 1} of ${photos.length}`
: descriptionForTaggedPhotos(photos)}
{selectedPhotoIndex === undefined &&
<ShareButton path={pathForTagShare(tag)} dim />}
</span>
<span className={cc(
'hidden sm:inline-block',
'text-right uppercase',
'text-gray-400 dark:text-gray-500',
)}>
{start === end
? start
: <>{start}<br /> {end}</>}
</span>
</div>
<PhotoHeader
entity={<PhotoTag tag={tag} />}
entityVerb="Tagged"
entityDescription={descriptionForTaggedPhotos(photos)}
photos={photos}
selectedPhoto={selectedPhoto}
sharePath={pathForTagShare(tag)}
/>
);
}

View File

@ -1,22 +1,17 @@
import { Photo, dateRangeForPhotos } from '@/photo';
import { Photo, descriptionForPhotoSet, labelForPhotos } from '@/photo';
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
import { capitalizeWords } from '@/utility/string';
const labelForPhotos = (photos: Photo[]) =>
photos.length === 1 ? 'Photo' : 'Photos';
export const titleForTag = (tag: string, photos:Photo[]) => [
capitalizeWords(tag.replaceAll('-', ' ')),
`(${photos.length} ${labelForPhotos(photos)})`,
].join(' ');
export const descriptionForTaggedPhotos = (
photos:Photo[],
photos: Photo[],
dateBased?: boolean,
) =>
dateBased
? dateRangeForPhotos(photos).description.toUpperCase()
: `${photos.length} Tagged ${labelForPhotos(photos)}`;
descriptionForPhotoSet(photos, 'tagged', dateBased);
export const generateMetaForTag = (tag: string, photos: Photo[]) => ({
url: absolutePathForTag(tag),