Merge pull request #141 from sambecker/new-nav
Improve photo navigation
This commit is contained in:
commit
cd38481052
@ -110,7 +110,6 @@ Application behavior can be changed by configuring the following environment var
|
||||
- `NEXT_PUBLIC_MATTE_PHOTOS = 1` constrains the size of each photo, and enables a surrounding border (potentially useful for photos with tall aspect ratios)
|
||||
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
|
||||
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
|
||||
- `NEXT_PUBLIC_HIDE_TITLE_FALLBACK_TEXT = 1` prevents showing "Untitled" for photos without titles
|
||||
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
||||
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
|
||||
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { pathForCameraShare } from '@/site/paths';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import { Camera, cameraFromPhoto } from '.';
|
||||
import PhotoCamera from './PhotoCamera';
|
||||
import { descriptionForCameraPhotos } from './meta';
|
||||
@ -22,9 +22,9 @@ export default function CameraHeader({
|
||||
}) {
|
||||
const camera = cameraFromPhoto(photos[0], cameraProp);
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
<PhotoHeader
|
||||
camera={camera}
|
||||
entity={<PhotoCamera {...{ camera }} contrast="high" hideAppleIcon />}
|
||||
entityVerb="Photo"
|
||||
entityDescription={
|
||||
descriptionForCameraPhotos(photos, undefined, count, dateRange)}
|
||||
photos={photos}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { descriptionForFocalLengthPhotos } from '.';
|
||||
import { pathForFocalLengthShare } from '@/site/paths';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoFocalLength from './PhotoFocalLength';
|
||||
|
||||
export default function FocalLengthHeader({
|
||||
@ -20,7 +20,8 @@ export default function FocalLengthHeader({
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
<PhotoHeader
|
||||
focal={focal}
|
||||
entity={<PhotoFocalLength focal={focal} contrast="high" />}
|
||||
entityDescription={descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
|
||||
@ -10,11 +10,9 @@ import {
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
|
||||
import { Photo } from '.';
|
||||
import { Photo, PhotoSetAttributes } from '.';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { GetPhotosOptions } from './db';
|
||||
|
||||
export type RevalidatePhoto = (
|
||||
@ -38,9 +36,6 @@ export default function InfinitePhotoScroll({
|
||||
initialOffset: number
|
||||
itemsPerPage: number
|
||||
sortBy?: GetPhotosOptions['sortBy']
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
cacheKey: string
|
||||
wrapMoreButtonInGrid?: boolean
|
||||
useCachedPhotos?: boolean
|
||||
@ -50,7 +45,7 @@ export default function InfinitePhotoScroll({
|
||||
onLastPhotoVisible: () => void
|
||||
revalidatePhoto?: RevalidatePhoto
|
||||
}) => ReactNode
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const { swrTimestamp, isUserSignedIn } = useAppState();
|
||||
|
||||
const key = `${swrTimestamp}-${cacheKey}`;
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { Photo, PhotoDateRange } from '.';
|
||||
import { Photo, PhotoDateRange, PhotoSetAttributes } from '.';
|
||||
import PhotoLarge from './PhotoLarge';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import PhotoGrid from './PhotoGrid';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import PhotoLinks from './PhotoLinks';
|
||||
import TagHeader from '@/tag/TagHeader';
|
||||
import { Camera } from '@/camera';
|
||||
import CameraHeader from '@/camera/CameraHeader';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import FilmSimulationHeader from '@/simulation/FilmSimulationHeader';
|
||||
import { TAG_HIDDEN } from '@/tag';
|
||||
import HiddenHeader from '@/tag/HiddenHeader';
|
||||
import FocalLengthHeader from '@/focal/FocalLengthHeader';
|
||||
import PhotoHeader from './PhotoHeader';
|
||||
|
||||
export default function PhotoDetailPage({
|
||||
photo,
|
||||
@ -31,22 +28,16 @@ export default function PhotoDetailPage({
|
||||
photo: Photo
|
||||
photos: Photo[]
|
||||
photosGrid?: Photo[]
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
shouldShare?: boolean
|
||||
includeFavoriteInAdminMenu?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{tag &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={tag === TAG_HIDDEN
|
||||
} & PhotoSetAttributes) {
|
||||
let customHeader: JSX.Element | undefined;
|
||||
|
||||
if (tag) {
|
||||
customHeader = tag === TAG_HIDDEN
|
||||
? <HiddenHeader
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
@ -61,47 +52,45 @@ export default function PhotoDetailPage({
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>}
|
||||
/>}
|
||||
{camera &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={
|
||||
<CameraHeader
|
||||
/>;
|
||||
} else if (camera) {
|
||||
customHeader = <CameraHeader
|
||||
camera={camera}
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>}
|
||||
/>}
|
||||
{simulation &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={
|
||||
<FilmSimulationHeader
|
||||
/>;
|
||||
} else if (simulation) {
|
||||
customHeader = <FilmSimulationHeader
|
||||
simulation={simulation}
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>}
|
||||
/>}
|
||||
{focal &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={
|
||||
<FocalLengthHeader
|
||||
/>;
|
||||
} else if (focal) {
|
||||
customHeader = <FocalLengthHeader
|
||||
focal={focal}
|
||||
photos={photos}
|
||||
selectedPhoto={photo}
|
||||
indexNumber={indexNumber}
|
||||
count={count}
|
||||
dateRange={dateRange}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SiteGrid
|
||||
className="mt-1.5 mb-6"
|
||||
contentMain={customHeader ?? <PhotoHeader
|
||||
selectedPhoto={photo}
|
||||
photos={photos}
|
||||
/>}
|
||||
/>}
|
||||
/>
|
||||
<AnimateItems
|
||||
className="md:mb-8"
|
||||
animateFromAppState
|
||||
@ -112,6 +101,7 @@ export default function PhotoDetailPage({
|
||||
primaryTag={tag}
|
||||
priority
|
||||
prefetchRelatedLinks
|
||||
showTitle={Boolean(customHeader)}
|
||||
showCamera={!camera}
|
||||
showSimulation={!simulation}
|
||||
shouldShare={shouldShare}
|
||||
@ -134,30 +124,6 @@ export default function PhotoDetailPage({
|
||||
focal={focal}
|
||||
animateOnFirstLoadOnly
|
||||
/>}
|
||||
contentSide={<AnimateItems
|
||||
animateOnFirstLoadOnly
|
||||
type="bottom"
|
||||
items={[
|
||||
<div
|
||||
key="PhotoLinks"
|
||||
className={clsx(
|
||||
'grid grid-cols-2',
|
||||
'gap-0.5 sm:gap-1',
|
||||
'md:flex md:gap-4',
|
||||
'user-select-none',
|
||||
)}
|
||||
>
|
||||
<PhotoLinks {...{
|
||||
photo,
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
}} />
|
||||
</div>,
|
||||
]}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Photo } from '.';
|
||||
import { Photo, PhotoSetAttributes } from '.';
|
||||
import PhotoMedium from './PhotoMedium';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { GRID_ASPECT_RATIO, HIGH_DENSITY_GRID } from '@/site/config';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import SelectTileOverlay from '@/components/SelectTileOverlay';
|
||||
@ -31,10 +29,6 @@ export default function PhotoGrid({
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
photoPriority?: boolean
|
||||
fast?: boolean
|
||||
animate?: boolean
|
||||
@ -46,7 +40,7 @@ export default function PhotoGrid({
|
||||
canSelect?: boolean
|
||||
onLastPhotoVisible?: () => void
|
||||
onAnimationComplete?: () => void
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const {
|
||||
isUserSignedIn,
|
||||
selectedPhotoIds,
|
||||
|
||||
@ -38,7 +38,7 @@ export default function PhotoGridContainer({
|
||||
return (
|
||||
<SiteGrid
|
||||
contentMain={<div className={clsx(
|
||||
header && 'space-y-8 mt-4',
|
||||
header && 'space-y-8 mt-1.5',
|
||||
)}>
|
||||
{header &&
|
||||
<AnimateItems
|
||||
|
||||
136
src/photo/PhotoHeader.tsx
Normal file
136
src/photo/PhotoHeader.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { clsx } from 'clsx/lite';
|
||||
import {
|
||||
Photo,
|
||||
PhotoDateRange,
|
||||
PhotoSetAttributes,
|
||||
dateRangeForPhotos,
|
||||
} from '.';
|
||||
import ShareButton from '@/components/ShareButton';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { ReactNode } from 'react';
|
||||
import { HIGH_DENSITY_GRID } from '@/site/config';
|
||||
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import PhotoPrevNext from './PhotoPrevNext';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||
|
||||
export default function PhotoHeader({
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
entity,
|
||||
entityVerb,
|
||||
entityDescription,
|
||||
sharePath,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
entity?: ReactNode
|
||||
entityVerb?: string
|
||||
entityDescription?: string
|
||||
sharePath?: string
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
} & PhotoSetAttributes) {
|
||||
const { start, end } = dateRangeForPhotos(photos, dateRange);
|
||||
|
||||
const selectedPhotoIndex = selectedPhoto
|
||||
? photos.findIndex(photo => photo.id === selectedPhoto.id)
|
||||
: undefined;
|
||||
|
||||
const paginationLabel =
|
||||
(indexNumber || (selectedPhotoIndex ?? 0 + 1)) + ' of ' +
|
||||
(count ?? photos.length);
|
||||
|
||||
const renderPrevNext = () =>
|
||||
<PhotoPrevNext {...{
|
||||
photo: selectedPhoto,
|
||||
photos,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
}} />;
|
||||
|
||||
const renderDateRange = () =>
|
||||
<span className="text-dim uppercase text-right">
|
||||
{start === end
|
||||
? start
|
||||
: <>{end}<br />– {start}</>}
|
||||
</span>;
|
||||
|
||||
return (
|
||||
<AnimateItems
|
||||
type="bottom"
|
||||
distanceOffset={10}
|
||||
animateOnFirstLoadOnly
|
||||
items={[<DivDebugBaselineGrid
|
||||
key="PhotosHeader"
|
||||
className={clsx(
|
||||
'grid gap-0.5 sm:gap-1 items-start grid-cols-4',
|
||||
HIGH_DENSITY_GRID
|
||||
? 'lg:grid-cols-6'
|
||||
: 'md:grid-cols-3 lg:grid-cols-4',
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'inline-flex uppercase',
|
||||
HIGH_DENSITY_GRID
|
||||
? 'col-span-2 sm:col-span-1 lg:col-span-2'
|
||||
: 'col-span-2 md:col-span-1',
|
||||
)}>
|
||||
{entity ?? (selectedPhoto
|
||||
? <PhotoLink
|
||||
photo={selectedPhoto}
|
||||
className="uppercase font-bold"
|
||||
>
|
||||
{selectedPhoto.title || formatDate(selectedPhoto.takenAt, 'tiny')}
|
||||
</PhotoLink>
|
||||
: undefined)}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'inline-flex',
|
||||
'gap-2 self-start',
|
||||
'uppercase text-dim',
|
||||
selectedPhotoIndex === undefined
|
||||
? HIGH_DENSITY_GRID
|
||||
? 'col-span-2 sm:col-span-1 md:col-span-2 lg:col-span-3'
|
||||
: 'col-span-2 sm:col-span-1 lg:col-span-2'
|
||||
: HIGH_DENSITY_GRID
|
||||
? 'sm:col-span-2 lg:col-span-3'
|
||||
: 'lg:col-span-2',
|
||||
)}>
|
||||
{entity && <>
|
||||
{selectedPhotoIndex !== undefined
|
||||
? <ResponsiveText shortText={paginationLabel}>
|
||||
{entityVerb || 'PHOTO'} {paginationLabel}
|
||||
</ResponsiveText>
|
||||
: entityDescription}
|
||||
{selectedPhotoIndex === undefined && sharePath &&
|
||||
<ShareButton
|
||||
className="translate-y-[1.5px]"
|
||||
path={sharePath}
|
||||
dim
|
||||
/>}
|
||||
</>}
|
||||
</span>
|
||||
<div className={clsx(
|
||||
selectedPhoto ? 'flex' : 'hidden sm:flex',
|
||||
'justify-end',
|
||||
)}>
|
||||
{selectedPhoto
|
||||
? renderPrevNext()
|
||||
: renderDateRange()}
|
||||
</div>
|
||||
</DivDebugBaselineGrid>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -25,10 +25,7 @@ import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
|
||||
import { sortTags } from '@/tag';
|
||||
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import {
|
||||
SHOULD_PREFETCH_ALL_LINKS,
|
||||
SHOW_PHOTO_TITLE_FALLBACK_TEXT,
|
||||
} from '@/site/config';
|
||||
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
||||
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
|
||||
import { RevalidatePhoto } from './InfinitePhotoScroll';
|
||||
import { useRef } from 'react';
|
||||
@ -38,11 +35,13 @@ import { useAppState } from '@/state/AppState';
|
||||
|
||||
export default function PhotoLarge({
|
||||
photo,
|
||||
className,
|
||||
primaryTag,
|
||||
priority,
|
||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
|
||||
revalidatePhoto,
|
||||
showTitle = true,
|
||||
showCamera = true,
|
||||
showSimulation = true,
|
||||
shouldShare = true,
|
||||
@ -55,11 +54,13 @@ export default function PhotoLarge({
|
||||
onVisible,
|
||||
}: {
|
||||
photo: Photo
|
||||
className?: string
|
||||
primaryTag?: string
|
||||
priority?: boolean
|
||||
prefetch?: boolean
|
||||
prefetchRelatedLinks?: boolean
|
||||
revalidatePhoto?: RevalidatePhoto
|
||||
showTitle?: boolean
|
||||
showCamera?: boolean
|
||||
showSimulation?: boolean
|
||||
shouldShare?: boolean
|
||||
@ -85,10 +86,13 @@ export default function PhotoLarge({
|
||||
|
||||
const { arePhotosMatted, isUserSignedIn } = useAppState();
|
||||
|
||||
const hasTitle =
|
||||
showTitle &&
|
||||
Boolean(photo.title);
|
||||
|
||||
const hasTitleContent =
|
||||
photo.title ||
|
||||
SHOW_PHOTO_TITLE_FALLBACK_TEXT ||
|
||||
photo.caption;
|
||||
hasTitle ||
|
||||
Boolean(photo.caption);
|
||||
|
||||
const hasMetaContent =
|
||||
showCameraContent ||
|
||||
@ -102,6 +106,7 @@ export default function PhotoLarge({
|
||||
return (
|
||||
<SiteGrid
|
||||
containerRef={ref}
|
||||
className={className}
|
||||
contentMain={
|
||||
<Link
|
||||
href={pathForPhoto({ photo })}
|
||||
@ -141,7 +146,7 @@ export default function PhotoLarge({
|
||||
{/* Meta */}
|
||||
<div className="pr-2 md:pr-0">
|
||||
<div className="md:relative flex gap-2 items-start">
|
||||
{(photo.title || SHOW_PHOTO_TITLE_FALLBACK_TEXT) &&
|
||||
{hasTitle &&
|
||||
<PhotoLink
|
||||
photo={photo}
|
||||
className="font-bold uppercase flex-grow"
|
||||
@ -158,7 +163,11 @@ export default function PhotoLarge({
|
||||
</div>
|
||||
<div className="space-y-baseline">
|
||||
{photo.caption &&
|
||||
<div className="uppercase">
|
||||
<div className={clsx(
|
||||
'uppercase',
|
||||
// Prevent collision with admin button
|
||||
isUserSignedIn && 'md:pr-7',
|
||||
)}>
|
||||
{photo.caption}
|
||||
</div>}
|
||||
{(showCameraContent || showTagsContent) &&
|
||||
@ -224,7 +233,7 @@ export default function PhotoLarge({
|
||||
photo={photo}
|
||||
className={clsx(
|
||||
'text-medium',
|
||||
// Prevent date collision with admin button
|
||||
// Prevent collision with admin button
|
||||
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Photo, titleForPhoto } from '@/photo';
|
||||
import { Photo, PhotoSetAttributes, titleForPhoto } from '@/photo';
|
||||
import Link from 'next/link';
|
||||
import { AnimationConfig } from '../components/AnimateItems';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { clsx } from 'clsx/lite';
|
||||
|
||||
export default function PhotoLink({
|
||||
@ -23,16 +21,12 @@ export default function PhotoLink({
|
||||
children,
|
||||
}: {
|
||||
photo?: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
scroll?: boolean
|
||||
prefetch?: boolean
|
||||
nextPhotoAnimation?: AnimationConfig
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const { setNextPhotoAnimation } = useAppState();
|
||||
|
||||
return (
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
|
||||
import {
|
||||
Photo,
|
||||
PhotoSetAttributes,
|
||||
altTextForPhoto,
|
||||
doesPhotoNeedBlurCompatibility,
|
||||
} from '.';
|
||||
import ImageMedium from '@/components/image/ImageMedium';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
||||
import { useRef } from 'react';
|
||||
import useOnVisible from '@/utility/useOnVisible';
|
||||
@ -24,16 +27,12 @@ export default function PhotoMedium({
|
||||
onVisible,
|
||||
}: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
selected?: boolean
|
||||
priority?: boolean
|
||||
prefetch?: boolean
|
||||
className?: string
|
||||
onVisible?: () => void
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useOnVisible(ref, onVisible);
|
||||
|
||||
@ -1,35 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
|
||||
import {
|
||||
Photo,
|
||||
PhotoSetAttributes,
|
||||
getNextPhoto,
|
||||
getPreviousPhoto,
|
||||
} from '@/photo';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { pathForPhoto } from '@/site/paths';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
|
||||
const LISTENER_KEYUP = 'keyup';
|
||||
|
||||
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
|
||||
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
|
||||
|
||||
export default function PhotoLinks({
|
||||
export default function PhotoPrevNext({
|
||||
photo,
|
||||
photos,
|
||||
photos = [],
|
||||
className,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
focal,
|
||||
}: {
|
||||
photo: Photo
|
||||
photos: Photo[]
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
}) {
|
||||
photo?: Photo
|
||||
photos?: Photo[]
|
||||
className?: string
|
||||
} & PhotoSetAttributes) {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
@ -37,8 +40,8 @@ export default function PhotoLinks({
|
||||
shouldRespondToKeyboardCommands,
|
||||
} = useAppState();
|
||||
|
||||
const previousPhoto = getPreviousPhoto(photo, photos);
|
||||
const nextPhoto = getNextPhoto(photo, photos);
|
||||
const previousPhoto = photo ? getPreviousPhoto(photo, photos) : undefined;
|
||||
const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRespondToKeyboardCommands) {
|
||||
@ -94,9 +97,14 @@ export default function PhotoLinks({
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
'flex items-center',
|
||||
className,
|
||||
)}>
|
||||
<div className="flex items-center gap-2 select-none">
|
||||
<PhotoLink
|
||||
photo={previousPhoto}
|
||||
className="select-none h-[1rem]"
|
||||
nextPhotoAnimation={ANIMATION_RIGHT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
@ -105,10 +113,17 @@ export default function PhotoLinks({
|
||||
scroll={false}
|
||||
prefetch
|
||||
>
|
||||
PREV
|
||||
<FiChevronLeft
|
||||
className="sm:hidden text-[1.1rem] translate-y-[-1px]"
|
||||
/>
|
||||
<span className="hidden sm:inline-block">PREV</span>
|
||||
</PhotoLink>
|
||||
<span className="text-extra-extra-dim">
|
||||
/
|
||||
</span>
|
||||
<PhotoLink
|
||||
photo={nextPhoto}
|
||||
className="select-none h-[1rem]"
|
||||
nextPhotoAnimation={ANIMATION_LEFT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
@ -117,8 +132,12 @@ export default function PhotoLinks({
|
||||
scroll={false}
|
||||
prefetch
|
||||
>
|
||||
NEXT
|
||||
<FiChevronRight
|
||||
className="sm:hidden text-[1.1rem] translate-y-[-1px]"
|
||||
/>
|
||||
<span className="hidden sm:inline-block">NEXT</span>
|
||||
</PhotoLink>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,85 +0,0 @@
|
||||
import { clsx } from 'clsx/lite';
|
||||
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';
|
||||
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
|
||||
export default function PhotoSetHeader({
|
||||
entity,
|
||||
entityVerb,
|
||||
entityDescription,
|
||||
photos,
|
||||
selectedPhoto,
|
||||
sharePath,
|
||||
indexNumber,
|
||||
count,
|
||||
dateRange,
|
||||
}: {
|
||||
entity: ReactNode
|
||||
entityVerb?: string
|
||||
entityDescription: string
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
sharePath?: string
|
||||
indexNumber?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
const { start, end } = dateRangeForPhotos(photos, dateRange);
|
||||
|
||||
const selectedPhotoIndex = selectedPhoto
|
||||
? photos.findIndex(photo => photo.id === selectedPhoto.id)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AnimateItems
|
||||
type="bottom"
|
||||
distanceOffset={10}
|
||||
animateOnFirstLoadOnly
|
||||
items={[<DivDebugBaselineGrid
|
||||
key="PhotosHeader"
|
||||
className={clsx(
|
||||
'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',
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'inline-flex uppercase',
|
||||
HIGH_DENSITY_GRID && 'sm:col-span-2',
|
||||
)}>
|
||||
{entity}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'inline-flex gap-2 self-start',
|
||||
'uppercase text-dim',
|
||||
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
|
||||
? `${entityVerb ? `${entityVerb} ` : ''}${indexNumber || (selectedPhotoIndex + 1)} of ${count ?? photos.length}`
|
||||
: entityDescription}
|
||||
{selectedPhotoIndex === undefined && sharePath &&
|
||||
<ShareButton
|
||||
className="translate-y-[1.5px]"
|
||||
path={sharePath}
|
||||
dim
|
||||
/>}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'hidden sm:inline-block',
|
||||
'text-right uppercase',
|
||||
'text-dim',
|
||||
)}>
|
||||
{start === end
|
||||
? start
|
||||
: <>{end}<br />– {start}</>}
|
||||
</span>
|
||||
</DivDebugBaselineGrid>]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,11 @@
|
||||
import PhotoOGTile from '@/photo/PhotoOGTile';
|
||||
import { absolutePathForPhoto, pathForPhoto } from '@/site/paths';
|
||||
import { Photo } from '.';
|
||||
import { Photo, PhotoSetAttributes } from '.';
|
||||
import ShareModal from '@/components/ShareModal';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
|
||||
export default function PhotoShareModal(props: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
return (
|
||||
<ShareModal
|
||||
pathShare={absolutePathForPhoto(props)}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
|
||||
import {
|
||||
Photo,
|
||||
PhotoSetAttributes,
|
||||
altTextForPhoto,
|
||||
doesPhotoNeedBlurCompatibility,
|
||||
} from '.';
|
||||
import ImageSmall from '@/components/image/ImageSmall';
|
||||
import Link from 'next/link';
|
||||
import { clsx } from 'clsx/lite';
|
||||
@ -6,8 +11,6 @@ import { pathForPhoto } from '@/site/paths';
|
||||
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
|
||||
import { useRef } from 'react';
|
||||
import useOnVisible from '@/utility/useOnVisible';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
|
||||
export default function PhotoSmall({
|
||||
photo,
|
||||
@ -21,15 +24,11 @@ export default function PhotoSmall({
|
||||
onVisible,
|
||||
}: {
|
||||
photo: Photo
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
selected?: boolean
|
||||
className?: string
|
||||
prefetch?: boolean
|
||||
onVisible?: () => void
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useOnVisible(ref, onVisible);
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Camera } from '@/camera';
|
||||
import { Lens } from '@/lens';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { PRIORITY_ORDER_ENABLED } from '@/site/config';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { PhotoSetAttributes } from '..';
|
||||
|
||||
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||
export const PHOTO_DEFAULT_LIMIT = 100;
|
||||
@ -12,16 +10,11 @@ export type GetPhotosOptions = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
query?: string
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
lens?: Lens
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
takenBefore?: Date
|
||||
takenAfterInclusive?: Date
|
||||
updatedBefore?: Date
|
||||
hidden?: 'exclude' | 'include' | 'only'
|
||||
};
|
||||
} & PhotoSetAttributes;
|
||||
|
||||
export const areOptionsSensitive = (options: GetPhotosOptions) =>
|
||||
options.hidden === 'include' || options.hidden === 'only';
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { Camera } from '@/camera';
|
||||
import { formatFocalLength } from '@/focal';
|
||||
import { Lens } from '@/lens';
|
||||
import { getNextImageUrlForRequest } from '@/services/next-image';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { HIGH_DENSITY_GRID, SHOW_EXIF_DATA } from '@/site/config';
|
||||
@ -99,6 +101,14 @@ export interface Photo extends PhotoDb {
|
||||
takenAtNaiveFormatted: string
|
||||
}
|
||||
|
||||
export interface PhotoSetAttributes {
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
lens?: Lens // Unimplemented as a set
|
||||
}
|
||||
|
||||
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
|
||||
const photoDb = camelcaseKeys(
|
||||
photoDbRaw as unknown as Record<string, unknown>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { FilmSimulation, descriptionForFilmSimulationPhotos } from '.';
|
||||
import { pathForFilmSimulationShare } from '@/site/paths';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoFilmSimulation from
|
||||
'@/simulation/PhotoFilmSimulation';
|
||||
|
||||
@ -21,9 +21,9 @@ export default function FilmSimulationHeader({
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
<PhotoHeader
|
||||
simulation={simulation}
|
||||
entity={<PhotoFilmSimulation {...{ simulation }} />}
|
||||
entityVerb="Photo"
|
||||
entityDescription={descriptionForFilmSimulationPhotos(
|
||||
photos, undefined, count, dateRange)}
|
||||
photos={photos}
|
||||
|
||||
@ -59,7 +59,6 @@ export default function SiteChecklistClient({
|
||||
arePhotosMatted,
|
||||
isBlurEnabled,
|
||||
isGeoPrivacyEnabled,
|
||||
showPhotoTitleFallbackText,
|
||||
isPriorityOrderEnabled,
|
||||
isAiTextGenerationEnabled,
|
||||
aiTextAutoGeneratedFields,
|
||||
@ -507,15 +506,6 @@ export default function SiteChecklistClient({
|
||||
collection/display of location-based data:
|
||||
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show photo title fallback text"
|
||||
status={showPhotoTitleFallbackText}
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to prevent
|
||||
showing {'"Untitled"'} for photos without titles:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_TITLE_FALLBACK_TEXT'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Priority order"
|
||||
status={isPriorityOrderEnabled}
|
||||
|
||||
@ -139,8 +139,6 @@ export const BLUR_ENABLED =
|
||||
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
|
||||
export const GEO_PRIVACY_ENABLED =
|
||||
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1';
|
||||
export const SHOW_PHOTO_TITLE_FALLBACK_TEXT =
|
||||
process.env.NEXT_PUBLIC_HIDE_TITLE_FALLBACK_TEXT !== '1';
|
||||
export const AI_TEXT_GENERATION_ENABLED =
|
||||
Boolean(process.env.OPENAI_SECRET_KEY);
|
||||
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
|
||||
@ -215,7 +213,6 @@ export const CONFIG_CHECKLIST_STATUS = {
|
||||
arePhotosMatted: MATTE_PHOTOS,
|
||||
isBlurEnabled: BLUR_ENABLED,
|
||||
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
|
||||
showPhotoTitleFallbackText: SHOW_PHOTO_TITLE_FALLBACK_TEXT,
|
||||
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
|
||||
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
|
||||
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
|
||||
|
||||
@ -142,6 +142,10 @@
|
||||
@apply
|
||||
text-gray-400/80 dark:text-gray-400/50
|
||||
}
|
||||
.text-extra-extra-dim {
|
||||
@apply
|
||||
text-gray-200 dark:text-gray-800
|
||||
}
|
||||
.text-icon {
|
||||
@apply
|
||||
text-gray-800 dark:text-gray-200
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo } from '@/photo';
|
||||
import { Photo, PhotoSetAttributes } from '@/photo';
|
||||
import { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config';
|
||||
import { Camera } from '@/camera';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
@ -75,13 +75,7 @@ export const PATHS_TO_CACHE = [
|
||||
...PATHS_ADMIN,
|
||||
];
|
||||
|
||||
interface PhotoPathParams {
|
||||
photo: PhotoOrPhotoId
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
}
|
||||
type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetAttributes;
|
||||
|
||||
// Absolute paths
|
||||
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
|
||||
@ -280,11 +274,7 @@ export const isPathProtected = (pathname?: string) =>
|
||||
|
||||
export const getPathComponents = (pathname = ''): {
|
||||
photoId?: string
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
focal?: number
|
||||
} => {
|
||||
} & PhotoSetAttributes => {
|
||||
const photoIdFromPhoto = pathname.match(
|
||||
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
|
||||
const photoIdFromTag = pathname.match(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Photo, photoQuantityText } from '@/photo';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import HiddenTag from './HiddenTag';
|
||||
|
||||
export default function HiddenHeader({
|
||||
@ -14,7 +14,7 @@ export default function HiddenHeader({
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
<PhotoHeader
|
||||
key="HiddenHeader"
|
||||
entity={<HiddenTag contrast="high" />}
|
||||
entityDescription={photoQuantityText(count, false)}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoTag from './PhotoTag';
|
||||
import { descriptionForTaggedPhotos, isTagFavs } from '.';
|
||||
import { pathForTagShare } from '@/site/paths';
|
||||
import PhotoSetHeader from '@/photo/PhotoSetHeader';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import FavsTag from './FavsTag';
|
||||
|
||||
export default function TagHeader({
|
||||
@ -21,7 +21,8 @@ export default function TagHeader({
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
return (
|
||||
<PhotoSetHeader
|
||||
<PhotoHeader
|
||||
tag={tag}
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag contrast="high" />
|
||||
: <PhotoTag tag={tag} contrast="high" />}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { format, parseISO, parse } from 'date-fns';
|
||||
|
||||
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
|
||||
const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy';
|
||||
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
|
||||
const DATE_STRING_FORMAT = 'dd MMM yyyy h:mma';
|
||||
@ -7,10 +8,12 @@ const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss';
|
||||
|
||||
type AmbiguousTimestamp = number | string;
|
||||
|
||||
type Length = 'short' | 'medium' | 'long';
|
||||
type Length = 'tiny' | 'short' | 'medium' | 'long';
|
||||
|
||||
export const formatDate = (date: Date, length: Length = 'long') => {
|
||||
switch (length) {
|
||||
case 'tiny':
|
||||
return format(date, DATE_STRING_FORMAT_TINY);
|
||||
case 'short':
|
||||
return format(date, DATE_STRING_FORMAT_SHORT);
|
||||
case 'medium':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user