Create tag-level photo view

This commit is contained in:
Sam Becker 2023-09-18 19:02:36 -05:00
parent aa43ee3012
commit 858a314018
18 changed files with 268 additions and 92 deletions

View File

@ -1,24 +1,17 @@
import AnimateItems from '@/components/AnimateItems';
import PhotoLinks from '@/photo/PhotoLinks';
import SiteGrid from '@/components/SiteGrid';
import {
GRID_THUMBNAILS_TO_SHOW_MAX,
ogImageDescriptionForPhoto,
titleForPhoto,
} from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import PhotoLarge from '@/photo/PhotoLarge';
import { cc } from '@/utility/css';
import { Metadata } from 'next';
import { BASE_URL } from '@/site/config';
import {
getPhoto,
getPhotosTakenAfterPhotoInclusive,
getPhotosTakenBeforePhoto,
} from '@/services/postgres';
import { redirect } from 'next/navigation';
import { absolutePathForPhotoImage } from '@/site/paths';
const THUMBNAILS_TO_SHOW_MAX = 12;
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
export const runtime = 'edge';
@ -34,6 +27,7 @@ export async function generateMetadata({
const title = titleForPhoto(photo);
const description = ogImageDescriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto(photo);
return {
title,
@ -42,7 +36,7 @@ export async function generateMetadata({
title,
images,
description,
url: `${BASE_URL}/p/${photo.idShort}`,
url,
},
twitter: {
title,
@ -67,36 +61,16 @@ export default async function PhotoPage({
const photosBefore = await getPhotosTakenBeforePhoto(photo, 1);
const photosAfter = await getPhotosTakenAfterPhotoInclusive(
photo,
THUMBNAILS_TO_SHOW_MAX + 1,
GRID_THUMBNAILS_TO_SHOW_MAX + 1,
);
const photos = photosBefore.concat(photosAfter);
return <>
{children}
<div className="md:space-y-8">
<AnimateItems
animateFromAppState
items={[<PhotoLarge
key={photo.id}
photo={photo}
priority
prefetchShare
/>]}
/>
<SiteGrid
sideFirstOnMobile
contentMain={<PhotoGrid
photos={photosAfter.slice(1)}
animateOnFirstLoadOnly
/>}
contentSide={<div className={cc(
'grid grid-cols-2',
'md:flex md:gap-4',
'user-select-none',
)}>
<PhotoLinks photo={photo} photos={photos} />
</div>}
/>
</div>
<PhotoDetailPage
photo={photo}
photos={photos}
photosGrid={photosAfter.slice(1)}
/>
</>;
}

View File

@ -4,11 +4,11 @@ import { redirect } from 'next/navigation';
export const runtime = 'edge';
interface Props {
export default async function Share({
params: { photoId },
}: {
params: { photoId: string }
}
export default async function Share({ params: { photoId }}: Props) {
}) {
const photo = await getPhoto(photoId);
if (!photo) { return redirect('/'); }

View File

@ -0,0 +1,69 @@
import {
ogImageDescriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
import {
getPhoto,
getPhotos,
} from '@/services/postgres';
import { redirect } from 'next/navigation';
import { absolutePathForPhoto, absolutePathForPhotoImage } from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
export const runtime = 'edge';
export async function generateMetadata({
params: { photoId, tag },
}: {
params: { photoId: string, tag: string }
}): Promise<Metadata> {
const photo = await getPhoto(photoId);
if (!photo) { return {}; }
const title = titleForPhoto(photo);
const description = ogImageDescriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto(photo, tag);
return {
title,
description,
openGraph: {
title,
images,
description,
url,
},
twitter: {
title,
description,
images,
card: 'summary_large_image',
},
};
}
export default async function PhotoTagPage({
params: { photoId, tag },
children,
}: {
params: { photoId: string, tag: string }
children: React.ReactNode
}) {
const photo = await getPhoto(photoId);
if (!photo) { redirect('/'); }
const photos = await getPhotos(undefined, undefined, undefined, tag);
return <>
{children}
<PhotoDetailPage
photo={photo}
photos={photos}
tag={tag}
/>
</>;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

View File

@ -0,0 +1,17 @@
import PhotoModal from '@/photo/PhotoModal';
import { getPhoto } from '@/services/postgres';
import { redirect } from 'next/navigation';
export const runtime = 'edge';
export default async function Share({
params: { photoId, tag },
}: {
params: { photoId: string, tag: string }
}) {
const photo = await getPhoto(photoId);
if (!photo) { return redirect('/'); }
return <PhotoModal photo={photo} tag={tag} />;
}

View File

@ -1,11 +1,9 @@
import SiteGrid from '@/components/SiteGrid';
import { dateRangeForPhotos } from '@/photo';
import PhotoGrid from '@/photo/PhotoGrid';
import { getPhotos } from '@/services/postgres';
import { absolutePathForTag, absolutePathForTagImage } from '@/site/paths';
import { descriptionForTaggedPhotos, titleForTag } from '@/tag';
import PhotoTag from '@/tag/PhotoTag';
import { cc } from '@/utility/css';
import TagHeader from '@/tag/TagHeader';
import { Metadata } from 'next';
interface TagProps {
@ -42,33 +40,11 @@ export async function generateMetadata({
export default async function TagPage({ params: { tag } }: TagProps) {
const photos = await getPhotos(undefined, undefined, undefined, tag);
const { start, end } = dateRangeForPhotos(photos);
return (
<SiteGrid
contentMain={<div className="space-y-8 mt-4">
<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(
'uppercase text-gray-400 dark:text-gray-500',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{descriptionForTaggedPhotos(photos)}
</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>
<PhotoGrid photos={photos} />
<TagHeader tag={tag} photos={photos} />
<PhotoGrid photos={photos} tag={tag} />
</div>}
/>
);

View File

@ -0,0 +1,64 @@
import AnimateItems from '@/components/AnimateItems';
import { Photo } from '.';
import PhotoLarge from './PhotoLarge';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from './PhotoGrid';
import { cc } from '@/utility/css';
import PhotoLinks from './PhotoLinks';
import TagHeader from '@/tag/TagHeader';
export default function PhotoDetailPage({
photo,
photos,
photosGrid,
tag,
}: {
photo: Photo
photos: Photo[]
photosGrid?: Photo[]
tag?: string
}) {
return (
<div>
{tag &&
<SiteGrid
className="mt-4 mb-8"
contentMain={
<TagHeader
key={tag}
tag={tag}
photos={photos}
selectedPhoto={photo}
/>}
/>}
<AnimateItems
className="md:mb-8"
animateFromAppState
items={[
<PhotoLarge
key={photo.id}
photo={photo}
tag={tag}
priority
prefetchShare
/>,
]}
/>
<SiteGrid
sideFirstOnMobile
contentMain={<PhotoGrid
photos={photosGrid ?? photos}
selectedPhoto={photo}
animateOnFirstLoadOnly
/>}
contentSide={<div className={cc(
'grid grid-cols-2',
'md:flex md:gap-4',
'user-select-none',
)}>
<PhotoLinks photo={photo} photos={photos} tag={tag} />
</div>}
/>
</div>
);
}

View File

@ -10,6 +10,7 @@ const PHOTOS_MAX = 35;
export default function PhotoGrid({
photos,
selectedPhoto,
tag,
offset = 0,
fast,
animateOnFirstLoadOnly,
@ -18,6 +19,7 @@ export default function PhotoGrid({
}: {
photos: Photo[]
selectedPhoto?: Photo
tag?: string
offset?: number
fast?: boolean
animate?: boolean
@ -42,6 +44,7 @@ export default function PhotoGrid({
<PhotoSmall
key={photo.id}
photo={photo}
tag={tag}
selected={photo.id === selectedPhoto?.id}
/>)}
/>

View File

@ -9,10 +9,12 @@ import PhotoTags from '@/tag/PhotoTags';
export default function PhotoLarge({
photo,
tag,
priority,
prefetchShare,
}: {
photo: Photo
tag?: string
priority?: boolean
prefetchShare?: boolean
}) {
@ -32,7 +34,7 @@ export default function PhotoLarge({
<ImageLarge
className="w-full"
alt={titleForPhoto(photo)}
href={pathForPhoto(photo)}
href={pathForPhoto(photo, tag)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
@ -48,7 +50,7 @@ export default function PhotoLarge({
)}>
{renderMiniGrid(<>
<Link
href={pathForPhoto(photo)}
href={pathForPhoto(photo, tag)}
className="font-bold uppercase"
>
{titleForPhoto(photo)}
@ -93,6 +95,7 @@ export default function PhotoLarge({
<div className="-translate-x-0.5">
<SharePhotoButton
photo={photo}
tag={tag}
prefetch={prefetchShare}
/>
</div>

View File

@ -9,11 +9,13 @@ import { pathForPhoto } from '@/site/paths';
export default function PhotoLink({
photo,
tag,
prefetch,
nextPhotoAnimation,
children,
}: {
photo?: Photo
tag?: string
prefetch?: boolean
nextPhotoAnimation?: AnimationConfig
children: ReactNode
@ -23,7 +25,7 @@ export default function PhotoLink({
return (
photo
? <Link
href={pathForPhoto(photo)}
href={pathForPhoto(photo, tag)}
prefetch={prefetch}
onClick={() => {
if (nextPhotoAnimation) {

View File

@ -3,8 +3,8 @@
import { useEffect } from 'react';
import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
import PhotoLink from './PhotoLink';
import { usePathname, useRouter } from 'next/navigation';
import { isPathPhotoShare, pathForPhoto } from '@/site/paths';
import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/site/paths';
import { useAppState } from '@/state';
import { AnimationConfig } from '@/components/AnimateItems';
@ -14,16 +14,16 @@ const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
export default function PhotoLinks({
photo,
photos,
tag,
}: {
photo: Photo
photos: Photo[]
tag?: string
}) {
const router = useRouter();
const pathname = usePathname();
const { setNextPhotoAnimation } = useAppState();
const isRouteShare = isPathPhotoShare(pathname);
const previousPhoto = getPreviousPhoto(photo, photos);
const nextPhoto = getNextPhoto(photo, photos);
@ -34,14 +34,14 @@ export default function PhotoLinks({
case 'J':
if (previousPhoto) {
setNextPhotoAnimation?.(ANIMATION_RIGHT);
router.push(pathForPhoto(previousPhoto, isRouteShare));
router.push(pathForPhoto(previousPhoto, tag));
}
break;
case 'ARROWRIGHT':
case 'L':
if (nextPhoto) {
setNextPhotoAnimation?.(ANIMATION_LEFT);
router.push(pathForPhoto(nextPhoto, isRouteShare));
router.push(pathForPhoto(nextPhoto, tag));
}
break;
case 'ESCAPE':
@ -51,13 +51,14 @@ export default function PhotoLinks({
};
window.addEventListener('keyup', onKeyUp);
return () => window.removeEventListener('keyup', onKeyUp);
}, [router, setNextPhotoAnimation, previousPhoto, nextPhoto, isRouteShare]);
}, [router, setNextPhotoAnimation, previousPhoto, nextPhoto, tag]);
return (
<>
<PhotoLink
photo={previousPhoto}
nextPhotoAnimation={ANIMATION_RIGHT}
tag={tag}
prefetch
>
PREV
@ -65,6 +66,7 @@ export default function PhotoLinks({
<PhotoLink
photo={nextPhoto}
nextPhotoAnimation={ANIMATION_LEFT}
tag={tag}
prefetch
>
NEXT

View File

@ -10,11 +10,17 @@ import { Photo } from '.';
import { toast } from 'sonner';
import { FiCheckSquare } from 'react-icons/fi';
export default function PhotoModal({ photo }: { photo: Photo }) {
const shareUrl = absolutePathForPhoto(photo);
export default function PhotoModal({
photo,
tag,
}: {
photo: Photo
tag?: string
}) {
const shareUrl = absolutePathForPhoto(photo, tag);
return (
<Modal onClosePath={pathForPhoto(photo)}>
<Modal onClosePath={pathForPhoto(photo, tag)}>
<div className="space-y-3 md:space-y-4 w-full">
<div className={cc(
'flex items-center gap-x-3',

View File

@ -6,14 +6,16 @@ import { pathForPhoto } from '@/site/paths';
export default function PhotoSmall({
photo,
tag,
selected,
}: {
photo: Photo
tag?: string
selected?: boolean
}) {
return (
<Link
href={pathForPhoto(photo)}
href={pathForPhoto(photo, tag)}
className={cc(
'active:brightness-75',
selected && 'brightness-50',

View File

@ -5,17 +5,19 @@ import { cc } from '@/utility/css';
import { pathForPhoto } from '@/site/paths';
export default function PhotoTiny({
className,
photo,
tag,
selected,
className,
}: {
className?: string
photo: Photo
tag?: string
selected?: boolean
className?: string
}) {
return (
<Link
href={pathForPhoto(photo)}
href={pathForPhoto(photo, tag)}
className={cc(
className,
'active:brightness-75',

View File

@ -1,19 +1,21 @@
import { Photo } from '.';
import { pathForPhoto } from '@/site/paths';
import { pathForPhotoShare } from '@/site/paths';
import { TbPhotoShare } from 'react-icons/tb';
import IconPathButton from '@/components/IconPathButton';
export default function SharePhotoButton({
photo,
tag,
prefetch,
}: {
photo: Photo
tag?: string
prefetch?: boolean
}) {
return (
<IconPathButton
icon={<TbPhotoShare size={17} />}
path={pathForPhoto(photo, true)}
path={pathForPhotoShare(photo, tag)}
prefetch={prefetch}
/>
);

View File

@ -16,6 +16,8 @@ import short from 'short-uuid';
const translator = short();
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
// Core EXIF data
export interface PhotoExif {
aspectRatio: number

View File

@ -11,18 +11,21 @@ export const PATH_ADMIN_UPLOAD_BLOB_HANDLER = `${PATH_ADMIN_UPLOAD}/blob`;
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
export const pathForPhoto = (photo: Photo, share?: boolean) =>
share
? `${PREFIX_PHOTO}/${photo.idShort}/share`
export const pathForPhoto = (photo: Photo, tag?: string) =>
tag
? `${pathForTag(tag)}/${photo.idShort}`
: `${PREFIX_PHOTO}/${photo.idShort}`;
export const pathForPhotoShare = (photo: Photo, tag?: string) =>
`${pathForPhoto(photo, tag)}/share`;
export const pathForPhotoEdit = (photo: Photo) =>
`${PATH_ADMIN_PHOTOS}/${photo.idShort}/edit`;
export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`;
export const absolutePathForPhoto = (photo: Photo) =>
`${BASE_URL}${pathForPhoto(photo)}`;
export const absolutePathForPhoto = (photo: Photo, tag?: string) =>
`${BASE_URL}${pathForPhoto(photo, tag)}`;
export const absolutePathForTag = (tag: string) =>
`${BASE_URL}${pathForTag(tag)}`;

46
src/tag/TagHeader.tsx Normal file
View File

@ -0,0 +1,46 @@
import { Photo, dateRangeForPhotos } from '@/photo';
import { cc } from '@/utility/css';
import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos } from '.';
export default function TagHeader({
tag,
photos,
selectedPhoto,
}: {
tag: string
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(
'uppercase text-gray-400 dark:text-gray-500',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{selectedPhotoIndex !== undefined
? `Tagged photo ${selectedPhotoIndex + 1} of ${photos.length}`
: descriptionForTaggedPhotos(photos)}
</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>
);
}