Merge pull request #28 from sambecker/universal-tags

Universal Photo Set Links
This commit is contained in:
Sam Becker 2023-12-17 14:21:37 -06:00 committed by GitHub
commit 35d4a89955
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1348 additions and 1168 deletions

View File

@ -9,26 +9,26 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@aws-sdk/client-s3": "3.470.0",
"@aws-sdk/s3-request-presigner": "3.470.0",
"@aws-sdk/client-s3": "3.474.0",
"@aws-sdk/s3-request-presigner": "3.474.0",
"@next/bundle-analyzer": "14.0.4",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.4",
"@types/react": "18.2.43",
"@types/react-dom": "18.2.17",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"@types/react": "18.2.45",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vercel/analytics": "^1.1.1",
"@vercel/blob": "^0.16.0",
"@vercel/blob": "^0.16.1",
"@vercel/postgres": "0.5.1",
"@vercel/speed-insights": "^1.0.1",
"@vercel/speed-insights": "^1.0.2",
"autoprefixer": "10.4.16",
"camelcase-keys": "^9.1.2",
"date-fns": "^2.30.0",
"eslint": "8.55.0",
"eslint": "8.56.0",
"eslint-config-next": "14.0.4",
"exifr": "^7.1.3",
"framer-motion": "^10.16.16",

2089
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,12 @@ export default async function GridPage({ searchParams }: PaginationParams) {
? <SiteGrid
contentMain={<PhotoGrid {...{ photos, showMorePath }} />}
contentSide={<div className="sticky top-4 space-y-4">
<PhotoGridSidebar {...{ tags, cameras, simulations, photosCount }} />
<PhotoGridSidebar {...{
tags,
cameras,
simulations,
photosCount,
}} />
</div>}
sideHiddenOnMobile
/>

7
src/cache/index.ts vendored
View File

@ -20,6 +20,7 @@ import {
getUniqueFilmSimulations,
getPhotosFilmSimulationDateRange,
getPhotosFilmSimulationCount,
getPhotosDateRange,
} from '@/services/vercel-postgres';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob';
@ -118,6 +119,12 @@ export const getPhotosCached = (
[KEY_PHOTOS, ...getPhotosCacheKeys(...args)],
)(...args).then(parseCachedPhotosDates);
export const getPhotosDateRangeCached =
unstable_cache(
getPhotosDateRange,
[KEY_PHOTOS, KEY_DATE_RANGE],
);
export const getPhotosCountCached =
unstable_cache(
getPhotosCount,

View File

@ -21,7 +21,7 @@ export default function CameraHeader({
const camera = cameraFromPhoto(photos[0], cameraProp);
return (
<PhotoSetHeader
entity={<PhotoCamera {...{ camera }} />}
entity={<PhotoCamera {...{ camera }} hideAppleIcon />}
entityVerb="Photo"
entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)}

View File

@ -1,56 +1,52 @@
import { AiFillApple } from 'react-icons/ai';
import { cc } from '@/utility/css';
import Link from 'next/link';
import { pathForCamera } from '@/site/paths';
import { IoMdCamera } from 'react-icons/io';
import { Camera } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
import { cc } from '@/utility/css';
export default function PhotoCamera({
camera,
showIcon = true,
hideApple = true,
hideAppleIcon,
type = 'icon-first',
badged,
dim,
countOnHover,
}: {
camera: Camera
showIcon?: boolean
hideApple?: boolean
hideAppleIcon?: boolean
countOnHover?: number
}) {
} & EntityLinkExternalProps) {
const isCameraApple = camera.make?.toLowerCase() === 'apple';
const showAppleIcon = !hideAppleIcon && isCameraApple;
return (
<span className="group">
<Link
href={pathForCamera(camera)}
className={cc(
'inline-flex items-center self-start',
'uppercase',
'hover:text-gray-900 dark:hover:text-gray-100',
)}
>
{showIcon && <>
<IoMdCamera
size={13}
className="text-icon translate-y-[-0.25px]"
/>
&nbsp;
</>}
{!(hideApple && camera.make?.toLowerCase() === 'apple') &&
<>
{camera.make?.toLowerCase() === 'apple'
? <AiFillApple
title="Apple"
className="text-icon translate-y-[-0.5px]"
size={14}
/>
: camera.make}
&nbsp;
</>}
<EntityLink
label={<>
{!isCameraApple && <>{camera.make}&nbsp;</>}
{camera.model}
</Link>
{countOnHover !== undefined &&
<span className="hidden group-hover:inline">
{' '}
{countOnHover}
</span>}
</span>
</>}
href={pathForCamera(camera)}
icon={showAppleIcon
? <AiFillApple
title="Apple"
className={cc(
'text-icon',
'translate-x-[-2.5px] translate-y-[2px]',
)}
size={15}
/>
: <IoMdCamera
size={13}
className={cc(
'text-icon',
'translate-x-[-1px] translate-y-[3.5px]',
)}
/>}
type={showAppleIcon && isCameraApple ? 'icon-first' : type}
badged={badged}
dim={dim}
hoverEntity={countOnHover}
/>
);
}

View File

@ -11,16 +11,16 @@ export default function Badge({
uppercase?: boolean
interactive?: boolean
}) {
const coreStyles = () => {
const stylesForType = () => {
switch (type) {
case 'primary': return cc(
'px-1.5 py-[0.3rem] leading-none rounded-md',
'px-1.5 py-[0.3rem] rounded-md',
'bg-gray-100/80 dark:bg-gray-900/80',
'border border-gray-200/60 dark:border-gray-800/75'
);
case 'secondary': return cc(
'px-[0.3rem] py-1 leading-none rounded-[0.25rem]',
'bg-gray-100 dark:bg-gray-800/60',
'px-[0.3rem] py-1 rounded-[0.25rem]',
'bg-gray-300/30 dark:bg-gray-700/50',
'text-medium',
'font-medium text-[0.7rem]',
interactive && 'hover:text-gray-900 dark:hover:text-gray-100',
@ -30,7 +30,8 @@ export default function Badge({
};
return (
<span className={cc(
coreStyles(),
'leading-none',
stylesForType(),
uppercase && 'uppercase tracking-wider',
)}>
{children}

View File

@ -0,0 +1,77 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import Badge from './Badge';
import { cc } from '@/utility/css';
export interface EntityLinkExternalProps {
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
badged?: boolean
dim?: boolean
}
export default function EntityLink({
label,
labelSmall,
href,
icon,
title,
type = 'icon-first',
badged,
hoverEntity,
dim,
}: {
label: ReactNode
labelSmall?: ReactNode
href: string
icon?: ReactNode
title?: string
hoverEntity?: ReactNode
} & EntityLinkExternalProps) {
const renderLabel = () => <>
<span className="xs:hidden">
{labelSmall ?? label}
</span>
<span className="hidden xs:inline-block">
{label}
</span>
</>;
return (
<span className="group inline-flex items-center gap-2 overflow-hidden">
<Link
href={href}
title={title}
className={cc(
'inline-flex gap-[0.23rem]',
!badged && 'text-main hover:text-gray-900 dark:hover:text-gray-100',
dim && 'text-dim',
)}
>
{type !== 'icon-only' && <>
{badged
? <span className="h-6 inline-flex items-center">
<Badge type="secondary" uppercase interactive>
{renderLabel()}
</Badge>
</span>
: <span className="uppercase">
{renderLabel()}
</span>}
</>}
{icon && type !== 'text-only' &&
<span className={cc(
'flex-shrink-0',
'text-dim inline-flex min-w-[0.9rem]',
type === 'icon-first' && 'order-first',
badged && 'translate-y-[4px]',
)}>
{icon}
</span>}
</Link>
{hoverEntity !== undefined &&
<span className="hidden group-hover:inline">
{hoverEntity}
</span>}
</span>
);
}

View File

@ -20,20 +20,21 @@ export default function HeaderList({
duration={0.5}
staggerDelay={0.05}
items={(title || icon
? [
<div key="header" className={cc(
? [<div
key="header"
className={cc(
'text-gray-900',
'dark:text-gray-100',
'flex items-center mb-0.5',
'flex items-center mb-0.5 gap-1',
'uppercase',
)}>
{icon &&
<span className="w-[17px]">
{icon}
</span>}
{title}
</div>,
]
)}
>
{icon &&
<span className="w-[1rem]">
{icon}
</span>}
{title}
</div>]
:[] as ReactNode[]
).concat(items)}
classNameItem="text-dim uppercase"

View File

@ -5,9 +5,7 @@ import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera';
import MorePhotos from '@/photo/MorePhotos';
import { FilmSimulation } from '@/simulation';
import { GRID_ASPECT_RATIO } from '@/site/config';
const HIGH_DENSITY = GRID_ASPECT_RATIO <= 1;
import { GRID_ASPECT_RATIO, HIGH_DENSITY_GRID } from '@/site/config';
export default function PhotoGrid({
photos,
@ -43,7 +41,7 @@ export default function PhotoGrid({
'grid gap-0.5 sm:gap-1',
small
? 'grid-cols-3 xs:grid-cols-6'
: HIGH_DENSITY
: HIGH_DENSITY_GRID
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center',

View File

@ -4,7 +4,7 @@ import HeaderList from '@/components/HeaderList';
import PhotoTag from '@/tag/PhotoTag';
import { FaTag } from 'react-icons/fa';
import { IoMdCamera } from 'react-icons/io';
import { photoQuantityText } from '.';
import { PhotoDateRange, dateRangeForPhotos, photoQuantityText } from '.';
import { Tags } from '@/tag';
import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation';
@ -17,12 +17,16 @@ export default function PhotoGridSidebar({
cameras,
simulations,
photosCount,
photosDateRange,
}: {
tags: Tags
cameras: Cameras
simulations: FilmSimulations
photosCount: number
photosDateRange?: PhotoDateRange
}) {
const { start, end } = dateRangeForPhotos(undefined, photosDateRange);
return (
<>
{tags.length > 0 && <HeaderList
@ -32,8 +36,9 @@ export default function PhotoGridSidebar({
<PhotoTag
key={tag}
tag={tag}
showIcon={false}
type="text-only"
countOnHover={count}
badged
/>)}
/>}
{cameras.length > 0 && <HeaderList
@ -48,15 +53,16 @@ export default function PhotoGridSidebar({
<PhotoCamera
key={cameraKey}
camera={camera}
showIcon={false}
type="text-only"
countOnHover={count}
hideApple
hideAppleIcon
badged
/>)}
/>}
{simulations.length > 0 && <HeaderList
title="Films"
icon={<PhotoFilmSimulationIcon
className="translate-y-[-0.5px]"
className="translate-y-[0.5px]"
/>}
items={simulations
.sort(sortFilmSimulationsWithCount)
@ -72,9 +78,16 @@ export default function PhotoGridSidebar({
/>
</div>)}
/>}
{photosCount > 0 && <HeaderList
items={[photoQuantityText(photosCount, false)]}
/>}
{photosCount > 0 && start
? <HeaderList
title={photoQuantityText(photosCount, false)}
items={start === end
? [start]
: [`${end} `, start]}
/>
: <HeaderList
items={[photoQuantityText(photosCount, false)]}
/>}
</>
);
}

View File

@ -61,6 +61,7 @@ export default function PhotoLarge({
/>}
contentSide={
<div className={cc(
'leading-snug',
'sticky top-4 self-start',
'grid grid-cols-2 md:grid-cols-1',
'gap-x-0.5 sm:gap-x-1',
@ -83,8 +84,7 @@ export default function PhotoLarge({
<div className="space-y-0.5">
<PhotoCamera
camera={camera}
showIcon={false}
hideApple={false}
type="text-only"
/>
{showSimulation && photo.filmSimulation &&
<div className="translate-x-[-0.3rem]">

View File

@ -3,6 +3,7 @@ import { Photo, PhotoDateRange, dateRangeForPhotos } from '.';
import ShareButton from '@/components/ShareButton';
import AnimateItems from '@/components/AnimateItems';
import { ReactNode } from 'react';
import { HIGH_DENSITY_GRID } from '@/site/config';
export default function PhotoSetHeader({
entity,
@ -37,14 +38,23 @@ export default function PhotoSetHeader({
items={[<div
key="PhotosHeader"
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',
'grid gap-0.5 sm:gap-1 items-start',
HIGH_DENSITY_GRID
? 'xs:grid-cols-2 sm:grid-cols-4 lg:grid-cols-5'
: 'xs:grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
)}>
{entity}
<span className={cc(
'inline-flex',
HIGH_DENSITY_GRID && 'sm:col-span-2',
)}>
{entity}
</span>
<span className={cc(
'inline-flex gap-2 items-center self-start',
'uppercase text-dim',
'sm:col-span-2 md:col-span-1 lg:col-span-2',
HIGH_DENSITY_GRID
? 'lg:col-span-2'
: 'sm:col-span-2 md:col-span-1 lg:col-span-2',
)}>
{selectedPhotoIndex !== undefined
// eslint-disable-next-line max-len

View File

@ -194,22 +194,28 @@ const sortPhotosByDate = (
: a.takenAt.getTime() - b.takenAt.getTime());
export const dateRangeForPhotos = (
photos: Photo[],
photos: Photo[] = [],
explicitDateRange?: PhotoDateRange,
) => {
const photosSorted = sortPhotosByDate(photos);
let start = '';
let end = '';
let description = '';
if (explicitDateRange || photos.length > 0) {
const photosSorted = sortPhotosByDate(photos);
start = formatDateFromPostgresString(
explicitDateRange?.start ?? photosSorted[photos.length - 1].takenAtNaive,
true,
);
end = formatDateFromPostgresString(
explicitDateRange?.end ?? photosSorted[0].takenAtNaive,
true
);
description = start === end
? start
: `${start}${end}`;
}
const start = formatDateFromPostgresString(
explicitDateRange?.start ?? photosSorted[photos.length - 1].takenAtNaive,
true,
);
const end = formatDateFromPostgresString(
explicitDateRange?.end ?? photosSorted[0].takenAtNaive,
true
);
const description = start === end
? start
: `${start}${end}`;
return { start, end, description };
};

View File

@ -288,6 +288,12 @@ const sqlGetPhotosFilmSimulationCount = async (
hidden IS NOT TRUE
`.then(({ rows }) => parseInt(rows[0].count, 10));
const sqlGetPhotosDateRange = async () => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
WHERE hidden IS NOT TRUE
`.then(({ rows }) => rows[0] as PhotoDateRange);
const sqlGetPhotosTagDateRange = async (tag: string) => sql`
SELECT MIN(taken_at_naive) as start, MAX(taken_at_naive) as end
FROM photos
@ -442,6 +448,8 @@ export const getPhoto = async (id: string): Promise<Photo | undefined> => {
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
};
export const getPhotosDateRange = () =>
safelyQueryPhotos(sqlGetPhotosDateRange);
export const getPhotosCount = () =>
safelyQueryPhotos(sqlGetPhotosCount);
export const getPhotosCountIncludingHidden = () =>

View File

@ -1,58 +1,35 @@
import { cc } from '@/utility/css';
import { labelForFilmSimulation } from '@/vendors/fujifilm';
import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon';
import Badge from '@/components/Badge';
import Link from 'next/link';
import { pathForFilmSimulation } from '@/site/paths';
import { FilmSimulation } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
export default function PhotoFilmSimulation({
simulation,
type = 'icon-last',
badged = true,
dim,
countOnHover,
}: {
simulation: FilmSimulation
type?: 'icon-last' | 'icon-first' | 'icon-only' | 'text-only'
badged?: boolean
countOnHover?: number
}) {
} & EntityLinkExternalProps) {
const { small, medium, large } = labelForFilmSimulation(simulation);
const renderContent = () => <>
<span className="xs:hidden">
{small}
</span>
<span className="hidden xs:inline-block">
{medium}
</span>
</>;
return (
<span className="group h-6 inline-flex items-center gap-2">
<Link
href={pathForFilmSimulation(simulation)}
title={`Film Simulation: ${large}`}
className="inline-flex items-center gap-1"
>
{type !== 'icon-only' && <>
{badged
? <Badge type="secondary" uppercase interactive>
{renderContent()}
</Badge>
: <span className="uppercase text-medium">{renderContent()}</span>}
</>}
{type !== 'text-only' && <span className={cc(
'translate-y-[-1px] text-dim',
type === 'icon-first' && 'order-first',
)}>
<PhotoFilmSimulationIcon {...{ simulation }} />
</span>}
</Link>
{countOnHover !== undefined &&
<span className="hidden group-hover:inline">
{countOnHover}
</span>}
</span>
<EntityLink
label={medium}
labelSmall={small}
href={pathForFilmSimulation(simulation)}
icon={<PhotoFilmSimulationIcon
simulation={simulation}
className="translate-y-[-1px]"
/>}
title={`Film Simulation: ${large}`}
type={type}
badged={badged}
dim={dim}
hoverEntity={countOnHover}
/>
);
}

View File

@ -59,6 +59,8 @@ export const GRID_ASPECT_RATIO = process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
export const OG_TEXT_BOTTOM_ALIGNMENT =
(process.env.NEXT_PUBLIC_OG_TEXT_ALIGNMENT ?? '').toUpperCase() === 'BOTTOM';
export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1;
export const CONFIG_CHECKLIST_STATUS = {
hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0,
hasBlob: HAS_VERCEL_BLOB || HAS_AWS_S3_STORAGE,

View File

@ -1,44 +1,30 @@
import Link from 'next/link';
import { pathForTag } from '@/site/paths';
import { FaTag } from 'react-icons/fa';
import { cc } from '@/utility/css';
import { formatTag } from '.';
import EntityLink, { EntityLinkExternalProps } from '@/components/EntityLink';
export default function PhotoTag({
tag,
showIcon = true,
type,
badged,
dim,
countOnHover,
}: {
tag: string
showIcon?: boolean
countOnHover?: number
}) {
} & EntityLinkExternalProps) {
return (
<span className="group">
<Link
href={pathForTag(tag)}
className={cc(
'inline-flex items-center gap-x-1.5 self-start',
'hover:text-gray-900 dark:hover:text-gray-100',
)}
>
{showIcon &&
<FaTag
size={11}
className={cc(
'flex-shrink-0',
'text-icon translate-y-[0.5px]',
)}
/>}
<span className="uppercase">
{formatTag(tag)}
</span>
</Link>
{countOnHover !== undefined &&
<span className="hidden group-hover:inline">
{' '}
{countOnHover}
</span>}
</span>
<EntityLink
label={formatTag(tag)}
href={pathForTag(tag)}
icon={<FaTag
size={11}
className="text-icon translate-y-[5px]"
/>}
type={type}
badged={badged}
dim={dim}
hoverEntity={countOnHover}
/>
);
}