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 MorePhotos from '@/components/MorePhotos';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
|
import { generateOgImageMetaForPhotos, getPhotosLimitForQuery } from '@/photo';
|
||||||
import PhotoDevice from '@/photo/PhotoDevice';
|
import PhotoDevice from '@/device/PhotoDevice';
|
||||||
import PhotoGrid from '@/photo/PhotoGrid';
|
import PhotoGrid from '@/photo/PhotoGrid';
|
||||||
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
import PhotosEmptyState from '@/photo/PhotosEmptyState';
|
||||||
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
|
import { MAX_PHOTOS_TO_SHOW_HOME } from '@/photo/image-response';
|
||||||
@ -69,6 +69,7 @@ export default async function GridPage({
|
|||||||
key={device}
|
key={device}
|
||||||
make={make}
|
make={make}
|
||||||
model={model}
|
model={model}
|
||||||
|
showIcon={false}
|
||||||
hideApple
|
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 { pathForPhoto, pathForPhotoShare } from '@/site/paths';
|
||||||
import PhotoTags from '@/tag/PhotoTags';
|
import PhotoTags from '@/tag/PhotoTags';
|
||||||
import ShareButton from '@/components/ShareButton';
|
import ShareButton from '@/components/ShareButton';
|
||||||
import PhotoDevice from './PhotoDevice';
|
import PhotoDevice from '../device/PhotoDevice';
|
||||||
|
|
||||||
export default function PhotoLarge({
|
export default function PhotoLarge({
|
||||||
photo,
|
photo,
|
||||||
@ -64,7 +64,12 @@ export default function PhotoLarge({
|
|||||||
{tagsToShow.length > 0 &&
|
{tagsToShow.length > 0 &&
|
||||||
<PhotoTags tags={tagsToShow} />}
|
<PhotoTags tags={tagsToShow} />}
|
||||||
</div>
|
</div>
|
||||||
<PhotoDevice make={photo.make} model={photo.model} />
|
<PhotoDevice
|
||||||
|
make={photo.make}
|
||||||
|
model={photo.model}
|
||||||
|
showIcon={false}
|
||||||
|
hideApple={false}
|
||||||
|
/>
|
||||||
</>)}
|
</>)}
|
||||||
{renderMiniGrid(<>
|
{renderMiniGrid(<>
|
||||||
<ul className={cc(
|
<ul className={cc(
|
||||||
|
|||||||
@ -169,6 +169,18 @@ export const translatePhotoId = (id: string) =>
|
|||||||
export const titleForPhoto = (photo: Photo) =>
|
export const titleForPhoto = (photo: Photo) =>
|
||||||
photo.title || 'Untitled';
|
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[]) => {
|
export const dateRangeForPhotos = (photos: Photo[]) => {
|
||||||
const start = photos[0].takenAtNaiveFormattedShort;
|
const start = photos[0].takenAtNaiveFormattedShort;
|
||||||
const end = photos[photos.length - 1].takenAtNaiveFormattedShort;
|
const end = photos[photos.length - 1].takenAtNaiveFormattedShort;
|
||||||
|
|||||||
@ -194,7 +194,9 @@ const sqlGetPhotosByDevice = async (
|
|||||||
model: string,
|
model: string,
|
||||||
) => sql<PhotoDb>`
|
) => sql<PhotoDb>`
|
||||||
SELECT * FROM photos
|
SELECT * FROM photos
|
||||||
WHERE make=${make} AND model=${model}
|
WHERE
|
||||||
|
LOWER(make)=${make} AND
|
||||||
|
LOWER(REPLACE(model, ' ', '-'))=${model}
|
||||||
ORDER BY taken_at DESC
|
ORDER BY taken_at DESC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { Photo, dateRangeForPhotos } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import { cc } from '@/utility/css';
|
|
||||||
import PhotoTag from './PhotoTag';
|
import PhotoTag from './PhotoTag';
|
||||||
import { descriptionForTaggedPhotos } from '.';
|
import { descriptionForTaggedPhotos } from '.';
|
||||||
import ShareButton from '@/components/ShareButton';
|
|
||||||
import { pathForTagShare } from '@/site/paths';
|
import { pathForTagShare } from '@/site/paths';
|
||||||
|
import PhotoHeader from '@/photo/PhotoHeader';
|
||||||
|
|
||||||
export default function TagHeader({
|
export default function TagHeader({
|
||||||
tag,
|
tag,
|
||||||
@ -14,38 +13,14 @@ export default function TagHeader({
|
|||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
selectedPhoto?: Photo
|
selectedPhoto?: Photo
|
||||||
}) {
|
}) {
|
||||||
const { start, end } = dateRangeForPhotos(photos);
|
|
||||||
|
|
||||||
const selectedPhotoIndex = selectedPhoto
|
|
||||||
? photos.findIndex(photo => photo.id === selectedPhoto.id)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cc(
|
<PhotoHeader
|
||||||
'flex flex-col gap-y-0.5',
|
entity={<PhotoTag tag={tag} />}
|
||||||
'xs:grid grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
entityVerb="Tagged"
|
||||||
)}>
|
entityDescription={descriptionForTaggedPhotos(photos)}
|
||||||
<PhotoTag tag={tag} />
|
photos={photos}
|
||||||
<span className={cc(
|
selectedPhoto={selectedPhoto}
|
||||||
'inline-flex gap-2 items-center self-start',
|
sharePath={pathForTagShare(tag)}
|
||||||
'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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,17 @@
|
|||||||
import { Photo, dateRangeForPhotos } from '@/photo';
|
import { Photo, descriptionForPhotoSet, labelForPhotos } from '@/photo';
|
||||||
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
|
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
|
||||||
import { capitalizeWords } from '@/utility/string';
|
import { capitalizeWords } from '@/utility/string';
|
||||||
|
|
||||||
const labelForPhotos = (photos: Photo[]) =>
|
|
||||||
photos.length === 1 ? 'Photo' : 'Photos';
|
|
||||||
|
|
||||||
export const titleForTag = (tag: string, photos:Photo[]) => [
|
export const titleForTag = (tag: string, photos:Photo[]) => [
|
||||||
capitalizeWords(tag.replaceAll('-', ' ')),
|
capitalizeWords(tag.replaceAll('-', ' ')),
|
||||||
`(${photos.length} ${labelForPhotos(photos)})`,
|
`(${photos.length} ${labelForPhotos(photos)})`,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
export const descriptionForTaggedPhotos = (
|
export const descriptionForTaggedPhotos = (
|
||||||
photos:Photo[],
|
photos: Photo[],
|
||||||
dateBased?: boolean,
|
dateBased?: boolean,
|
||||||
) =>
|
) =>
|
||||||
dateBased
|
descriptionForPhotoSet(photos, 'tagged', dateBased);
|
||||||
? dateRangeForPhotos(photos).description.toUpperCase()
|
|
||||||
: `${photos.length} Tagged ${labelForPhotos(photos)}`;
|
|
||||||
|
|
||||||
export const generateMetaForTag = (tag: string, photos: Photo[]) => ({
|
export const generateMetaForTag = (tag: string, photos: Photo[]) => ({
|
||||||
url: absolutePathForTag(tag),
|
url: absolutePathForTag(tag),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user