Add tag share files

This commit is contained in:
Sam Becker 2023-09-21 20:30:28 -05:00
parent a023cbf311
commit 96f8c18893
19 changed files with 319 additions and 136 deletions

View File

@ -1,6 +1,6 @@
import {
GRID_THUMBNAILS_TO_SHOW_MAX,
ogImageDescriptionForPhoto,
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
@ -27,7 +27,7 @@ export async function generateMetadata({
if (!photo) { return {}; }
const title = titleForPhoto(photo);
const description = ogImageDescriptionForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto(photo);

View File

@ -1,5 +1,5 @@
import { getPhotoCached } from '@/cache';
import PhotoModal from '@/photo/PhotoModal';
import PhotoShareModal from '@/photo/PhotoShareModal';
import { redirect } from 'next/navigation';
export const runtime = 'edge';
@ -13,5 +13,5 @@ export default async function Share({
if (!photo) { return redirect('/'); }
return <PhotoModal photo={photo} />;
return <PhotoShareModal photo={photo} />;
}

View File

@ -1,5 +1,5 @@
import {
ogImageDescriptionForPhoto,
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { Metadata } from 'next';
@ -33,7 +33,7 @@ export async function generateMetadata({
if (!photo) { return {}; }
const title = titleForPhoto(photo);
const description = ogImageDescriptionForPhoto(photo);
const description = descriptionForPhoto(photo);
const images = absolutePathForPhotoImage(photo);
const url = absolutePathForPhoto(photo, tag);

View File

@ -1,5 +1,5 @@
import { getPhotoCached } from '@/cache';
import PhotoModal from '@/photo/PhotoModal';
import PhotoShareModal from '@/photo/PhotoShareModal';
import { redirect } from 'next/navigation';
export const runtime = 'edge';
@ -13,5 +13,5 @@ export default async function Share({
if (!photo) { return redirect('/'); }
return <PhotoModal photo={photo} tag={tag} />;
return <PhotoShareModal photo={photo} tag={tag} />;
}

View File

@ -45,11 +45,12 @@ export async function generateMetadata({
};
}
export default async function TagPage({ params: { tag } }: TagProps) {
export default async function TagPage({ params: { tag } }:TagProps) {
const photos = await getPhotosCached({ tag });
return (
<SiteGrid
key="Tag Grid"
contentMain={<div className="space-y-8 mt-4">
<TagHeader tag={tag} photos={photos} />
<PhotoGrid photos={photos} tag={tag} />

View File

@ -0,0 +1,25 @@
import { getPhotosCached } from '@/cache';
import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from '@/photo/PhotoGrid';
import TagHeader from '@/tag/TagHeader';
import TagShareModal from '@/tag/TagShareModal';
export const runtime = 'edge';
export default async function Share({
params: { tag },
}: {
params: { tag: string }
}) {
const photos = await getPhotosCached({ tag });
return <>
<TagShareModal tag={tag} photos={photos} />
<SiteGrid
key="Tag Grid"
contentMain={<div className="space-y-8 mt-4">
<TagHeader tag={tag} photos={photos} />
<PhotoGrid photos={photos} tag={tag} animate={false} />
</div>}
/>
</>;
}

View File

@ -48,6 +48,7 @@ export default function IconPathButton({
router.push(path, { scroll: shouldScroll }))}
isLoading={shouldShowLoader}
className={cc(
'translate-y-[-0.5px]',
'active:translate-y-[1px]',
'text-gray-500 active:text-gray-600',
'dark:text-gray-400 dark:active:text-gray-300',

130
src/components/OGTile.tsx Normal file
View File

@ -0,0 +1,130 @@
'use client';
import { useEffect, useState } from 'react';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { BiError } from 'react-icons/bi';
import Spinner from '@/components/Spinner';
import { IMAGE_OG_SIZE } from '../photo/image-response';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function OGTile({
title,
description,
path,
pathImageAbsolute,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
}: {
title: string
description: string
path: string
pathImageAbsolute: string
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
}) {
const [loadingStateInternal, setLoadingStateInternal] =
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
const { width, height, ratio } = IMAGE_OG_SIZE;
return (
<Link
href={path}
className={cc(
'group',
'block w-full rounded-md overflow-hidden',
'border shadow-sm',
'border-gray-200 dark:border-gray-800',
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
)}
>
<div
className="relative"
style={{ aspectRatio: ratio }}
>
{loadingState === 'loading' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-10',
'flex items-center justify-center',
)}>
<Spinner size={40} />
</div>}
{loadingState === 'failed' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-[11]',
'flex items-center justify-center',
'text-red-400',
)}>
<BiError size={32} />
</div>}
{(loadingState === 'loading' || loadingState === 'loaded') &&
<img
alt={title}
className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-0',
'w-full',
loadingState === 'loading' && 'opacity-0',
'transition-opacity',
)}
src={pathImageAbsolute}
width={width}
height={height}
onLoad={() => {
if (onLoad) {
onLoad();
} else {
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) {
setTimeout(() => {
setLoadingStateInternal('loading');
}, retryTime);
}
}}
/>}
</div>
<div className={cc(
'md:text-lg',
'flex flex-col gap-1 p-3',
'font-sans leading-none',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 group-active:dark:bg-gray-900/50',
'group-hover:bg-gray-100 group-hover:dark:bg-gray-900/70',
'border-t border-gray-200 dark:border-gray-800',
)}>
<div className="text-gray-800 dark:text-white font-medium">
{title}
</div>
<div className="text-gray-500">
{description}
</div>
</div>
</Link>
);
};

View File

@ -0,0 +1,25 @@
import { TbPhotoShare } from 'react-icons/tb';
import IconPathButton from '@/components/IconPathButton';
export default function ShareButton({
path,
prefetch,
shouldScroll,
dim,
}: {
path: string
prefetch?: boolean
shouldScroll?: boolean
dim?: boolean
}) {
return (
<IconPathButton {...{
path,
icon: <TbPhotoShare size={17} className={dim
? 'text-gray-400 dark:text-gray-500'
: undefined} />,
prefetch,
shouldScroll,
}} />
);
}

View File

@ -1,26 +1,26 @@
'use client';
import Modal from '@/components/Modal';
import PhotoOGTile from '@/photo/PhotoOGTile';
import { absolutePathForPhoto, pathForPhoto } from '@/site/paths';
import { TbPhotoShare } from 'react-icons/tb';
import { cc } from '@/utility/css';
import { BiCopy } from 'react-icons/bi';
import { Photo } from '.';
import { toast } from 'sonner';
import { FiCheckSquare } from 'react-icons/fi';
import { ReactNode } from 'react';
export default function PhotoModal({
photo,
tag,
export default function ShareModal({
title = 'Share',
pathShare,
pathClose,
children,
}: {
photo: Photo
tag?: string
title?: string
pathShare: string
pathClose: string
children: ReactNode
}) {
const shareUrl = absolutePathForPhoto(photo, tag);
return (
<Modal onClosePath={pathForPhoto(photo, tag)}>
<Modal onClosePath={pathClose}>
<div className="space-y-3 md:space-y-4 w-full">
<div className={cc(
'flex items-center gap-x-3',
@ -28,10 +28,10 @@ export default function PhotoModal({
)}>
<TbPhotoShare size={22} className="hidden xs:block" />
<div className="flex-grow">
Share Photo
{title}
</div>
</div>
<PhotoOGTile photo={photo} />
{children}
<div className={cc(
'rounded-md',
'w-full overflow-hidden',
@ -39,7 +39,7 @@ export default function PhotoModal({
'border border-gray-200 dark:border-gray-800',
)}>
<div className="truncate p-2 w-full">
{shareUrl}
{pathShare}
</div>
<div
className={cc(
@ -50,7 +50,7 @@ export default function PhotoModal({
'cursor-pointer',
)}
onClick={() => {
navigator.clipboard.writeText(shareUrl);
navigator.clipboard.writeText(pathShare);
toast(
'Link to photo copied',
{ icon: <FiCheckSquare size={16} /> },

View File

@ -13,6 +13,7 @@ export default function PhotoGrid({
tag,
offset = 0,
fast,
animate = true,
animateOnFirstLoadOnly,
staggerOnFirstLoadOnly = true,
showMore,
@ -35,6 +36,7 @@ export default function PhotoGrid({
'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',
)}
type={animate === false ? 'none' : undefined}
duration={fast ? 0.3 : undefined}
staggerDelay={0.075}
distanceOffset={40}

View File

@ -3,9 +3,9 @@ import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/ImageLarge';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { pathForPhoto } from '@/site/paths';
import SharePhotoButton from './SharePhotoButton';
import { pathForPhoto, pathForPhotoShare } from '@/site/paths';
import PhotoTags from '@/tag/PhotoTags';
import ShareButton from '@/components/ShareButton';
export default function PhotoLarge({
photo,
@ -99,9 +99,8 @@ export default function PhotoLarge({
{photo.takenAtNaiveFormatted}
</div>
<div className="-translate-x-0.5">
<SharePhotoButton
photo={photo}
tag={tag}
<ShareButton
path={pathForPhotoShare(photo, tag)}
prefetch={prefetchShare}
shouldScroll={shouldScrollOnShare}
/>

View File

@ -1,17 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import {
Photo,
ogImageDescriptionForPhoto,
descriptionForPhoto,
titleForPhoto,
} from '@/photo';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { BiError } from 'react-icons/bi';
import { absolutePathForPhotoImage, pathForPhoto } from '@/site/paths';
import Spinner from '@/components/Spinner';
import { IMAGE_OG_SIZE } from './image-response';
import OGTile from '@/components/OGTile';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
@ -30,102 +23,17 @@ export default function PhotoOGTile({
riseOnHover?: boolean
retryTime?: number
}) {
const [loadingStateInternal, setLoadingStateInternal] =
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
const { width, height, ratio } = IMAGE_OG_SIZE;
return (
<Link
key={photo.id}
href={pathForPhoto(photo)}
className={cc(
'group',
'block w-full rounded-md overflow-hidden',
'border shadow-sm',
'border-gray-200 dark:border-gray-800',
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
)}
>
<div
className="relative"
style={{ aspectRatio: ratio }}
>
{loadingState === 'loading' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-10',
'flex items-center justify-center',
)}>
<Spinner size={40} />
</div>}
{loadingState === 'failed' &&
<div className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-[11]',
'flex items-center justify-center',
'text-red-400',
)}>
<BiError size={32} />
</div>}
{(loadingState === 'loading' || loadingState === 'loaded') &&
<img
alt={titleForPhoto(photo)}
className={cc(
'absolute top-0 left-0 right-0 bottom-0 z-0',
'w-full',
loadingState === 'loading' && 'opacity-0',
'transition-opacity',
)}
src={absolutePathForPhotoImage(photo)}
width={width}
height={height}
onLoad={() => {
if (onLoad) {
onLoad();
} else {
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) {
setTimeout(() => {
setLoadingStateInternal('loading');
}, retryTime);
}
}}
/>}
</div>
<div className={cc(
'md:text-lg',
'flex flex-col gap-1 p-3',
'font-sans leading-none',
'bg-gray-50 dark:bg-gray-900/50',
'group-active:bg-gray-50 group-active:dark:bg-gray-900/50',
'group-hover:bg-gray-100 group-hover:dark:bg-gray-900/70',
'border-t border-gray-200 dark:border-gray-800',
)}>
<div className="text-gray-800 dark:text-white font-medium">
{titleForPhoto(photo)}
</div>
<div className="text-gray-500">
{ogImageDescriptionForPhoto(photo)}
</div>
</div>
</Link>
<OGTile {...{
title: titleForPhoto(photo),
description: descriptionForPhoto(photo),
path: pathForPhoto(photo),
pathImageAbsolute: absolutePathForPhotoImage(photo),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/>
);
};

View File

@ -0,0 +1,22 @@
import PhotoOGTile from '@/photo/PhotoOGTile';
import { absolutePathForPhoto, pathForPhoto } from '@/site/paths';
import { Photo } from '.';
import ShareModal from '@/components/ShareModal';
export default function PhotoShareModal({
photo,
tag,
}: {
photo: Photo
tag?: string
}) {
return (
<ShareModal
title="Share Photo"
pathShare={absolutePathForPhoto(photo, tag)}
pathClose={pathForPhoto(photo, tag)}
>
<PhotoOGTile photo={photo} />
</ShareModal>
);
};

View File

@ -115,7 +115,7 @@ export const photoStatsAsString = (photo: Photo) => [
photo.isoFormatted,
].join(' ');
export const ogImageDescriptionForPhoto = (photo: Photo) =>
export const descriptionForPhoto = (photo: Photo) =>
photo.takenAtNaiveFormatted?.toUpperCase();
export const getPreviousPhoto = (photo: Photo, photos: Photo[]) => {

View File

@ -5,6 +5,8 @@ const PREFIX_PHOTO = '/p';
const PREFIX_TAG = '/t';
const PREFIX_ADMIN = '/admin';
const SHARE = 'share';
export const PATH_ADMIN_PHOTOS = `${PREFIX_ADMIN}/photos`;
export const PATH_ADMIN_UPLOAD = `${PREFIX_ADMIN}/uploads`;
export const PATH_ADMIN_UPLOAD_BLOB_HANDLER = `${PATH_ADMIN_UPLOAD}/blob`;
@ -17,13 +19,16 @@ export const pathForPhoto = (photo: Photo, tag?: string) =>
: `${PREFIX_PHOTO}/${photo.id}`;
export const pathForPhotoShare = (photo: Photo, tag?: string) =>
`${pathForPhoto(photo, tag)}/share`;
`${pathForPhoto(photo, tag)}/${SHARE}`;
export const pathForPhotoEdit = (photo: Photo) =>
`${PATH_ADMIN_PHOTOS}/${photo.id}/edit`;
export const pathForTag = (tag: string) => `${PREFIX_TAG}/${tag}`;
export const pathForTagShare = (tag: string) =>
`${pathForTag(tag)}/${SHARE}`;
export const absolutePathForPhoto = (photo: Photo, tag?: string) =>
`${BASE_URL}${pathForPhoto(photo, tag)}`;

View File

@ -2,6 +2,8 @@ import { Photo, dateRangeForPhotos } from '@/photo';
import { cc } from '@/utility/css';
import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos } from '.';
import ShareButton from '@/components/ShareButton';
import { pathForTagShare } from '@/site/paths';
export default function TagHeader({
tag,
@ -25,12 +27,15 @@ export default function TagHeader({
)}>
<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',

38
src/tag/TagOGTile.tsx Normal file
View File

@ -0,0 +1,38 @@
import { Photo } from '@/photo';
import { absolutePathForTagImage, pathForTag } from '@/site/paths';
import OGTile from '@/components/OGTile';
import { descriptionForTaggedPhotos, titleForTag } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function TagOGTile({
tag,
photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
}: {
tag: string
photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
}) {
return (
<OGTile {...{
title: titleForTag(tag, photos),
description: descriptionForTaggedPhotos(photos, true),
path: pathForTag(tag),
pathImageAbsolute: absolutePathForTagImage(tag),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/>
);
};

22
src/tag/TagShareModal.tsx Normal file
View File

@ -0,0 +1,22 @@
import { absolutePathForTag, pathForTag } from '@/site/paths';
import { Photo } from '../photo';
import ShareModal from '@/components/ShareModal';
import TagOGTile from './TagOGTile';
export default function TagShareModal({
tag,
photos,
}: {
tag: string
photos: Photo[]
}) {
return (
<ShareModal
title="Share Photos"
pathShare={absolutePathForTag(tag)}
pathClose={pathForTag(tag)}
>
<TagOGTile tag={tag} photos={photos} />
</ShareModal>
);
};