Merge pull request #141 from sambecker/new-nav

Improve photo navigation
This commit is contained in:
Sam Becker 2024-09-01 22:03:58 -05:00 committed by GitHub
commit cd38481052
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 332 additions and 324 deletions

View File

@ -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

View File

@ -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}

View File

@ -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,

View File

@ -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}`;

View File

@ -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>
);

View File

@ -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,

View File

@ -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
View 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>,
]}
/>
);
}

View File

@ -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',
)}
/>

View File

@ -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 (

View File

@ -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);

View File

@ -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>
);
};

View File

@ -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>]}
/>
);
}

View File

@ -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)}

View File

@ -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);

View File

@ -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';

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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)}

View File

@ -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" />}

View File

@ -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':