Create device photo set view
This commit is contained in:
parent
6e68aa16c5
commit
af7af53401
71
src/app/(static)/d/[device]/page.tsx
Normal file
71
src/app/(static)/d/[device]/page.tsx
Normal 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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
/>)}
|
||||
/>}
|
||||
|
||||
26
src/device/DeviceHeader.tsx
Normal file
26
src/device/DeviceHeader.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/device/PhotoDevice.tsx
Normal file
45
src/device/PhotoDevice.tsx
Normal 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} />
|
||||
|
||||
</>}
|
||||
{!(hideApple && make?.toLowerCase() === 'apple') &&
|
||||
<>
|
||||
{make?.toLowerCase() === 'apple'
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
className="translate-y-[-0.5px]"
|
||||
size={14}
|
||||
/>
|
||||
: make}
|
||||
|
||||
</>}
|
||||
{model}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
14
src/device/index.ts
Normal file
14
src/device/index.ts
Normal 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);
|
||||
@ -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}
|
||||
|
||||
</>}
|
||||
{model}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/photo/PhotoHeader.tsx
Normal file
54
src/photo/PhotoHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
`;
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user