Merge branch 'main' into add-public-downloads

This commit is contained in:
Sam Becker 2024-09-21 14:54:28 -05:00
commit 9ad7f89dfb
36 changed files with 1691 additions and 1574 deletions

View File

@ -2,7 +2,7 @@
https://github.com/sambecker/exif-photo-blog/assets/169298/4253ea54-558a-4358-8834-89943cfbafb4 https://github.com/sambecker/exif-photo-blog/assets/169298/4253ea54-558a-4358-8834-89943cfbafb4
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-title=Photo+Blog&demo-description=Store+photos+with+original+camera+data&demo-url=https%3A%2F%2Fphotos.sambecker.com&demo-image=https%3A%2F%2Fphotos.sambecker.com%2Ftemplate-image-tight&project-name=Photo+Blog&repository-name=exif-photo-blog&repository-url=https%3A%2F%2Fgithub.com%2Fsambecker%2Fexif-photo-blog&from=templates&skippable-integrations=1&teamCreateStatus=hidden&stores=%5B%7B%22type%22%3A%22postgres%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/sambecker-pro/clone?demo-description=Store%20photos%20with%20original%20camera%20data&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F39rys245Px3FVBGRJNYEON%2Fbf68d5c052bda9e9e5bec21878764bc3%2Fimage.png&demo-title=Photo%20Blog&demo-url=https%3A%2F%2Fphotos.sambecker.com&from=templates&project-name=Photo%20Blog&repository-name=exif-photo-blog&repository-url=https%3A%2F%2Fgithub.com%2Fsambecker%2Fexif-photo-blog&skippable-integrations=1&stores=%5B%7B%22type%22%3A%22postgres%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D&teamCreateStatus=hidden)
Demo App Demo App
- -
@ -98,8 +98,8 @@ Application behavior can be changed by configuring the following environment var
#### Site meta #### Site meta
- `NEXT_PUBLIC_SITE_TITLE` (seen in browser tab) - `NEXT_PUBLIC_SITE_TITLE` (seen in browser tab)
- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, under title) - `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, beneath title)
- `NEXT_PUBLIC_SITE_ABOUT` (e.g., seen in grid sidebar) - `NEXT_PUBLIC_SITE_ABOUT` (seen in grid sidebar—accepted rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
#### Site behavior #### Site behavior
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage - `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
@ -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_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_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_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_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` - `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
- `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public image downloads - `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public image downloads

17
__tests__/html.test.ts Normal file
View File

@ -0,0 +1,17 @@
import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html';
import { parameterize } from '@/utility/string';
describe('HTML', () => {
it('safely parses', () => {
expect(safelyParseFormattedHtml('<p>TEXT</p>')).toBe('TEXT');
expect(safelyParseFormattedHtml('<b>TEXT</b>')).toBe('<b>TEXT</b>');
});
it('detects br-style paragraph breaks', () => {
expect(htmlHasBrParagraphBreaks('TEXT<br><br>')).toBeTruthy();
expect(htmlHasBrParagraphBreaks('TEXT<br /><br />')).toBeTruthy();
expect(htmlHasBrParagraphBreaks('TEXT<br><br />')).toBeTruthy();
expect(htmlHasBrParagraphBreaks('TEXT')).toBeFalsy();
expect(htmlHasBrParagraphBreaks('TEXT<br/>')).toBeFalsy();
expect(htmlHasBrParagraphBreaks('TEXT<br />')).toBeFalsy();
});
});

View File

@ -1,7 +1,7 @@
{ {
"name": "exif-photo-blog", "name": "exif-photo-blog",
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -9,54 +9,56 @@
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^0.0.53", "@ai-sdk/openai": "^0.0.61",
"@aws-sdk/client-s3": "3.637.0", "@aws-sdk/client-s3": "3.654.0",
"@aws-sdk/s3-request-presigner": "3.637.0", "@aws-sdk/s3-request-presigner": "3.654.0",
"@next/bundle-analyzer": "14.2.6", "@next/bundle-analyzer": "14.2.13",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.9",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.13",
"@types/node": "^22.5.0", "@types/node": "^22.5.5",
"@types/pg": "^8.11.6", "@types/pg": "^8.11.10",
"@types/react": "18.3.4", "@types/react": "18.3.8",
"@types/react-dom": "18.3.0", "@types/react-dom": "18.3.0",
"@types/sanitize-html": "^2.13.0",
"@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0", "@typescript-eslint/parser": "^7.17.0",
"@upstash/ratelimit": "^2.0.1", "@upstash/ratelimit": "^2.0.3",
"@vercel/analytics": "^1.3.1", "@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.23.4", "@vercel/blob": "^0.24.0",
"@vercel/kv": "^2.0.0", "@vercel/kv": "^2.0.0",
"@vercel/speed-insights": "^1.0.12", "@vercel/speed-insights": "^1.0.12",
"ai": "^3.3.17", "ai": "^3.4.0",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^4.1.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-next": "14.2.6", "eslint-config-next": "14.2.13",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"framer-motion": "^11.3.30", "framer-motion": "^11.5.6",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"next": "^14.2.6", "next": "14.2.13",
"next-auth": "5.0.0-beta.19", "next-auth": "5.0.0-beta.21",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pg": "^8.12.0", "pg": "^8.13.0",
"postcss": "8.4.41", "postcss": "8.4.47",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwindcss": "3.4.10", "tailwindcss": "3.4.12",
"ts-exif-parser": "^0.2.2", "ts-exif-parser": "^0.2.2",
"typescript": "5.5.4", "typescript": "5.6.2",
"undici": "^6.19.8", "undici": "^6.19.8",
"use-debounce": "^10.0.3" "use-debounce": "^10.0.3"
} }

2366
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import { getUniqueTagsCached } from '@/photo/cache';
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient'; import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
export default async function AdminBatchEditPanel() { export default async function AdminBatchEditPanel() {
const uniqueTags = await getUniqueTagsCached(); const uniqueTags = await getUniqueTagsCached().catch(() => []);
return ( return (
<AdminBatchEditPanelClient {...{ uniqueTags }} /> <AdminBatchEditPanelClient {...{ uniqueTags }} />
); );

View File

@ -1,6 +1,6 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForCameraShare } from '@/site/paths'; import { pathForCameraShare } from '@/site/paths';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import { Camera, cameraFromPhoto } from '.'; import { Camera, cameraFromPhoto } from '.';
import PhotoCamera from './PhotoCamera'; import PhotoCamera from './PhotoCamera';
import { descriptionForCameraPhotos } from './meta'; import { descriptionForCameraPhotos } from './meta';
@ -22,9 +22,9 @@ export default function CameraHeader({
}) { }) {
const camera = cameraFromPhoto(photos[0], cameraProp); const camera = cameraFromPhoto(photos[0], cameraProp);
return ( return (
<PhotoSetHeader <PhotoHeader
camera={camera}
entity={<PhotoCamera {...{ camera }} contrast="high" hideAppleIcon />} entity={<PhotoCamera {...{ camera }} contrast="high" hideAppleIcon />}
entityVerb="Photo"
entityDescription={ entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)} descriptionForCameraPhotos(photos, undefined, count, dateRange)}
photos={photos} photos={photos}

View File

@ -31,7 +31,7 @@ import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5'; import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { queryPhotosByTitleAction } from '@/photo/actions'; import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri'; import { RiToolsFill } from 'react-icons/ri';
import { BiLockAlt, BiSolidUser } from 'react-icons/bi'; import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
import { HiDocumentText } from 'react-icons/hi'; import { HiDocumentText } from 'react-icons/hi';
@ -86,12 +86,14 @@ export default function CommandKClient({
hiddenPhotosCount, hiddenPhotosCount,
selectedPhotoIds, selectedPhotoIds,
setSelectedPhotoIds, setSelectedPhotoIds,
isGridHighDensity,
arePhotosMatted, arePhotosMatted,
shouldShowBaselineGrid, shouldShowBaselineGrid,
shouldDebugImageFallbacks, shouldDebugImageFallbacks,
setIsCommandKOpen: setIsOpen, setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid, setShouldShowBaselineGrid,
setIsGridHighDensity,
setArePhotosMatted, setArePhotosMatted,
setShouldDebugImageFallbacks, setShouldDebugImageFallbacks,
} = useAppState(); } = useAppState();
@ -149,27 +151,33 @@ export default function CommandKClient({
useEffect(() => { useEffect(() => {
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) { if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) {
setIsLoading(true); setIsLoading(true);
queryPhotosByTitleAction(queryDebounced).then(photos => { searchPhotosAction(queryDebounced)
if (isOpenRef.current) { .then(photos => {
setQueriedSections(photos.length > 0 if (isOpenRef.current) {
? [{ setQueriedSections(photos.length > 0
heading: 'Photos', ? [{
accessory: <TbPhoto size={14} />, heading: 'Photos',
items: photos.map(photo => ({ accessory: <TbPhoto size={14} />,
label: titleForPhoto(photo), items: photos.map(photo => ({
keywords: getKeywordsForPhoto(photo), label: titleForPhoto(photo),
annotation: <PhotoDate {...{ photo }} />, keywords: getKeywordsForPhoto(photo),
accessory: <PhotoSmall photo={photo} />, annotation: <PhotoDate {...{ photo }} />,
path: pathForPhoto({ photo }), accessory: <PhotoSmall photo={photo} />,
})), path: pathForPhoto({ photo }),
}] })),
: []); }]
} else { : []);
// Ignore stale requests that come in after dialog is closed } else {
// Ignore stale requests that come in after dialog is closed
setQueriedSections([]);
}
setIsLoading(false);
})
.catch(e => {
console.error(e);
setQueriedSections([]); setQueriedSections([]);
} setIsLoading(false);
setIsLoading(false); });
});
} }
}, [queryDebounced, isPending]); }, [queryDebounced, isPending]);
@ -240,6 +248,10 @@ export default function CommandKClient({
label: 'Toggle Photo Matting', label: 'Toggle Photo Matting',
action: () => setArePhotosMatted?.(prev => !prev), action: () => setArePhotosMatted?.(prev => !prev),
annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined, annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined,
}, {
label: 'Toggle High Density Grid',
action: () => setIsGridHighDensity?.(prev => !prev),
annotation: isGridHighDensity ? <FaCheck size={12} /> : undefined,
}, { }, {
label: 'Toggle Image Fallbacks', label: 'Toggle Image Fallbacks',
action: () => setShouldDebugImageFallbacks?.(prev => !prev), action: () => setShouldDebugImageFallbacks?.(prev => !prev),

View File

@ -1,7 +1,7 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { descriptionForFocalLengthPhotos } from '.'; import { descriptionForFocalLengthPhotos } from '.';
import { pathForFocalLengthShare } from '@/site/paths'; import { pathForFocalLengthShare } from '@/site/paths';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFocalLength from './PhotoFocalLength'; import PhotoFocalLength from './PhotoFocalLength';
export default function FocalLengthHeader({ export default function FocalLengthHeader({
@ -20,7 +20,8 @@ export default function FocalLengthHeader({
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
return ( return (
<PhotoSetHeader <PhotoHeader
focal={focal}
entity={<PhotoFocalLength focal={focal} contrast="high" />} entity={<PhotoFocalLength focal={focal} contrast="high" />}
entityDescription={descriptionForFocalLengthPhotos( entityDescription={descriptionForFocalLengthPhotos(
photos, photos,

View File

@ -10,11 +10,9 @@ import {
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions'; import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
import { Photo } from '.'; import { Photo, PhotoSetAttributes } from '.';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { GetPhotosOptions } from './db'; import { GetPhotosOptions } from './db';
export type RevalidatePhoto = ( export type RevalidatePhoto = (
@ -38,9 +36,6 @@ export default function InfinitePhotoScroll({
initialOffset: number initialOffset: number
itemsPerPage: number itemsPerPage: number
sortBy?: GetPhotosOptions['sortBy'] sortBy?: GetPhotosOptions['sortBy']
tag?: string
camera?: Camera
simulation?: FilmSimulation
cacheKey: string cacheKey: string
wrapMoreButtonInGrid?: boolean wrapMoreButtonInGrid?: boolean
useCachedPhotos?: boolean useCachedPhotos?: boolean
@ -50,7 +45,7 @@ export default function InfinitePhotoScroll({
onLastPhotoVisible: () => void onLastPhotoVisible: () => void
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
}) => ReactNode }) => ReactNode
}) { } & PhotoSetAttributes) {
const { swrTimestamp, isUserSignedIn } = useAppState(); const { swrTimestamp, isUserSignedIn } = useAppState();
const key = `${swrTimestamp}-${cacheKey}`; const key = `${swrTimestamp}-${cacheKey}`;

View File

@ -1,18 +1,15 @@
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { Photo, PhotoDateRange } from '.'; import { Photo, PhotoDateRange, PhotoSetAttributes } from '.';
import PhotoLarge from './PhotoLarge'; import PhotoLarge from './PhotoLarge';
import SiteGrid from '@/components/SiteGrid'; import SiteGrid from '@/components/SiteGrid';
import PhotoGrid from './PhotoGrid'; import PhotoGrid from './PhotoGrid';
import { clsx } from 'clsx/lite';
import PhotoLinks from './PhotoLinks';
import TagHeader from '@/tag/TagHeader'; import TagHeader from '@/tag/TagHeader';
import { Camera } from '@/camera';
import CameraHeader from '@/camera/CameraHeader'; import CameraHeader from '@/camera/CameraHeader';
import { FilmSimulation } from '@/simulation';
import FilmSimulationHeader from '@/simulation/FilmSimulationHeader'; import FilmSimulationHeader from '@/simulation/FilmSimulationHeader';
import { TAG_HIDDEN } from '@/tag'; import { TAG_HIDDEN } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader'; import HiddenHeader from '@/tag/HiddenHeader';
import FocalLengthHeader from '@/focal/FocalLengthHeader'; import FocalLengthHeader from '@/focal/FocalLengthHeader';
import PhotoHeader from './PhotoHeader';
export default function PhotoDetailPage({ export default function PhotoDetailPage({
photo, photo,
@ -31,77 +28,69 @@ export default function PhotoDetailPage({
photo: Photo photo: Photo
photos: Photo[] photos: Photo[]
photosGrid?: Photo[] photosGrid?: Photo[]
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
indexNumber?: number indexNumber?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
shouldShare?: boolean shouldShare?: boolean
includeFavoriteInAdminMenu?: boolean includeFavoriteInAdminMenu?: boolean
}) { } & PhotoSetAttributes) {
let customHeader: JSX.Element | undefined;
if (tag) {
customHeader = tag === TAG_HIDDEN
? <HiddenHeader
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count ?? 0}
/>
: <TagHeader
key={tag}
tag={tag}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>;
} else if (camera) {
customHeader = <CameraHeader
camera={camera}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>;
} else if (simulation) {
customHeader = <FilmSimulationHeader
simulation={simulation}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>;
} else if (focal) {
customHeader = <FocalLengthHeader
focal={focal}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>;
}
return ( return (
<div> <div>
{tag && <SiteGrid
<SiteGrid className="mt-1.5 mb-6"
className="mt-4 mb-8" contentMain={customHeader ?? <PhotoHeader
contentMain={tag === TAG_HIDDEN selectedPhoto={photo}
? <HiddenHeader photos={photos}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count ?? 0}
/>
: <TagHeader
key={tag}
tag={tag}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>}
/>}
{camera &&
<SiteGrid
className="mt-4 mb-8"
contentMain={
<CameraHeader
camera={camera}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>}
/>}
{simulation &&
<SiteGrid
className="mt-4 mb-8"
contentMain={
<FilmSimulationHeader
simulation={simulation}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>}
/>}
{focal &&
<SiteGrid
className="mt-4 mb-8"
contentMain={
<FocalLengthHeader
focal={focal}
photos={photos}
selectedPhoto={photo}
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
/>}
/>} />}
/>
<AnimateItems <AnimateItems
className="md:mb-8" className="md:mb-8"
animateFromAppState animateFromAppState
@ -112,6 +101,8 @@ export default function PhotoDetailPage({
primaryTag={tag} primaryTag={tag}
priority priority
prefetchRelatedLinks prefetchRelatedLinks
showTitle={Boolean(customHeader)}
showTitleAsH1
showCamera={!camera} showCamera={!camera}
showSimulation={!simulation} showSimulation={!simulation}
shouldShare={shouldShare} shouldShare={shouldShare}
@ -134,30 +125,6 @@ export default function PhotoDetailPage({
focal={focal} focal={focal}
animateOnFirstLoadOnly 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> </div>
); );

View File

@ -1,12 +1,10 @@
'use client'; 'use client';
import { Photo } from '.'; import { Photo, PhotoSetAttributes } from '.';
import PhotoMedium from './PhotoMedium'; import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera'; import { GRID_ASPECT_RATIO } from '@/site/config';
import { FilmSimulation } from '@/simulation';
import { GRID_ASPECT_RATIO, HIGH_DENSITY_GRID } from '@/site/config';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay'; import SelectTileOverlay from '@/components/SelectTileOverlay';
@ -31,10 +29,6 @@ export default function PhotoGrid({
}: { }: {
photos: Photo[] photos: Photo[]
selectedPhoto?: Photo selectedPhoto?: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
photoPriority?: boolean photoPriority?: boolean
fast?: boolean fast?: boolean
animate?: boolean animate?: boolean
@ -46,11 +40,12 @@ export default function PhotoGrid({
canSelect?: boolean canSelect?: boolean
onLastPhotoVisible?: () => void onLastPhotoVisible?: () => void
onAnimationComplete?: () => void onAnimationComplete?: () => void
}) { } & PhotoSetAttributes) {
const { const {
isUserSignedIn, isUserSignedIn,
selectedPhotoIds, selectedPhotoIds,
setSelectedPhotoIds, setSelectedPhotoIds,
isGridHighDensity,
} = useAppState(); } = useAppState();
return ( return (
@ -59,7 +54,7 @@ export default function PhotoGrid({
'grid gap-0.5 sm:gap-1', 'grid gap-0.5 sm:gap-1',
small small
? 'grid-cols-3 xs:grid-cols-6' ? 'grid-cols-3 xs:grid-cols-6'
: HIGH_DENSITY_GRID : isGridHighDensity
? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-6' ? 'grid-cols-2 xs:grid-cols-4 lg:grid-cols-6'
: 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4', : 'grid-cols-2 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
'items-center', 'items-center',

View File

@ -38,7 +38,7 @@ export default function PhotoGridContainer({
return ( return (
<SiteGrid <SiteGrid
contentMain={<div className={clsx( contentMain={<div className={clsx(
header && 'space-y-8 mt-4', header && 'space-y-8 mt-1.5',
)}> )}>
{header && {header &&
<AnimateItems <AnimateItems

View File

@ -16,6 +16,8 @@ import { useAppState } from '@/state/AppState';
import { useMemo } from 'react'; import { useMemo } from 'react';
import HiddenTag from '@/tag/HiddenTag'; import HiddenTag from '@/tag/HiddenTag';
import { SITE_ABOUT } from '@/site/config'; import { SITE_ABOUT } from '@/site/config';
import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html';
import { clsx } from 'clsx/lite';
export default function PhotoGridSidebar({ export default function PhotoGridSidebar({
tags, tags,
@ -43,10 +45,14 @@ export default function PhotoGridSidebar({
{SITE_ABOUT && <HeaderList {SITE_ABOUT && <HeaderList
items={[<p items={[<p
key="about" key="about"
className="max-w-60 normal-case text-main" className={clsx(
> 'max-w-60 normal-case text-main',
{SITE_ABOUT} htmlHasBrParagraphBreaks(SITE_ABOUT) && 'pb-2',
</p>]} )}
dangerouslySetInnerHTML={{
__html: safelyParseFormattedHtml(SITE_ABOUT),
}}
/>]}
/>} />}
{tags.length > 0 && <HeaderList {tags.length > 0 && <HeaderList
title='Tags' title='Tags'

168
src/photo/PhotoHeader.tsx Normal file
View File

@ -0,0 +1,168 @@
'use client';
import { clsx } from 'clsx/lite';
import {
Photo,
PhotoDateRange,
PhotoSetAttributes,
dateRangeForPhotos,
titleForPhoto,
} from '.';
import ShareButton from '@/components/ShareButton';
import AnimateItems from '@/components/AnimateItems';
import { ReactNode } from 'react';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import PhotoPrevNext from './PhotoPrevNext';
import PhotoLink from './PhotoLink';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppState } from '@/state/AppState';
export default function PhotoHeader({
tag,
camera,
simulation,
focal,
photos,
selectedPhoto,
entity,
entityVerb = 'PHOTO',
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 { isGridHighDensity } = useAppState();
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 headerType = selectedPhotoIndex === undefined
? 'photo-set'
: entity
? 'photo-detail-with-entity'
: 'photo-detail';
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>;
const renderContentA = () => entity ?? (
selectedPhoto !== undefined &&
<PhotoLink
photo={selectedPhoto}
className="uppercase font-bold text-ellipsis truncate"
>
{titleForPhoto(selectedPhoto, true)}
</PhotoLink>);
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',
isGridHighDensity
? 'lg:grid-cols-6'
: 'md:grid-cols-3 lg:grid-cols-4',
)}>
{/* Content A: Filter Set or Photo Title */}
<div className={clsx(
'inline-flex uppercase',
headerType === 'photo-set'
? isGridHighDensity
? 'col-span-2 sm:col-span-1 lg:col-span-2'
: 'col-span-2 sm:col-span-1'
: headerType === 'photo-detail-with-entity'
? isGridHighDensity
? 'col-span-2 sm:col-span-1 lg:col-span-2'
: 'col-span-2 sm:col-span-1'
: isGridHighDensity
? 'col-span-3 sm:col-span-3 lg:col-span-5'
: 'col-span-3 md:col-span-2 lg:col-span-3',
)}>
{headerType === 'photo-detail-with-entity'
? renderContentA()
: <h1>{renderContentA()}</h1>}
</div>
{/* Content B: Filter Set Meta or Photo Pagination */}
<div className={clsx(
'inline-flex',
'gap-2 self-start',
'uppercase text-dim',
headerType === 'photo-set'
? isGridHighDensity
? 'col-span-2 lg:col-span-3'
: 'col-span-2 md:col-span-1 lg:col-span-2'
: headerType === 'photo-detail-with-entity'
? isGridHighDensity
? 'sm:col-span-2 lg:col-span-3'
: 'sm:col-span-2 md:col-span-1 lg:col-span-2'
: 'hidden',
)}>
{entity && <>
{headerType === 'photo-set'
? <>
{entityDescription}
{sharePath &&
<ShareButton
className="translate-y-[1.5px]"
path={sharePath}
dim
/>}
</>
: <ResponsiveText shortText={paginationLabel}>
{entityVerb} {paginationLabel}
</ResponsiveText>}
</>}
</div>
{/* Content C: Nav */}
<div className={clsx(
headerType === 'photo-set'
? 'hidden sm:flex'
: 'flex',
'justify-end',
)}>
{selectedPhoto
? renderPrevNext()
: renderDateRange()}
</div>
</DivDebugBaselineGrid>,
]}
/>
);
}

View File

@ -28,7 +28,6 @@ import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import PhotoLink from './PhotoLink'; import PhotoLink from './PhotoLink';
import { import {
SHOULD_PREFETCH_ALL_LINKS, SHOULD_PREFETCH_ALL_LINKS,
SHOW_PHOTO_TITLE_FALLBACK_TEXT,
ALLOW_PUBLIC_DOWNLOADS, ALLOW_PUBLIC_DOWNLOADS,
} from '@/site/config'; } from '@/site/config';
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient'; import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
@ -40,11 +39,14 @@ import { useAppState } from '@/state/AppState';
export default function PhotoLarge({ export default function PhotoLarge({
photo, photo,
className,
primaryTag, primaryTag,
priority, priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS, prefetch = SHOULD_PREFETCH_ALL_LINKS,
prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS, prefetchRelatedLinks = SHOULD_PREFETCH_ALL_LINKS,
revalidatePhoto, revalidatePhoto,
showTitle = true,
showTitleAsH1,
showCamera = true, showCamera = true,
showSimulation = true, showSimulation = true,
shouldShare = true, shouldShare = true,
@ -57,11 +59,14 @@ export default function PhotoLarge({
onVisible, onVisible,
}: { }: {
photo: Photo photo: Photo
className?: string
primaryTag?: string primaryTag?: string
priority?: boolean priority?: boolean
prefetch?: boolean prefetch?: boolean
prefetchRelatedLinks?: boolean prefetchRelatedLinks?: boolean
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
showTitle?: boolean
showTitleAsH1?: boolean
showCamera?: boolean showCamera?: boolean
showSimulation?: boolean showSimulation?: boolean
shouldShare?: boolean shouldShare?: boolean
@ -87,10 +92,13 @@ export default function PhotoLarge({
const { arePhotosMatted, isUserSignedIn } = useAppState(); const { arePhotosMatted, isUserSignedIn } = useAppState();
const hasTitle =
showTitle &&
Boolean(photo.title);
const hasTitleContent = const hasTitleContent =
photo.title || hasTitle ||
SHOW_PHOTO_TITLE_FALLBACK_TEXT || Boolean(photo.caption);
photo.caption;
const hasMetaContent = const hasMetaContent =
showCameraContent || showCameraContent ||
@ -101,9 +109,17 @@ export default function PhotoLarge({
hasTitleContent || hasTitleContent ||
hasMetaContent; hasMetaContent;
const renderPhotoLink = () =>
<PhotoLink
photo={photo}
className="font-bold uppercase flex-grow"
prefetch={prefetch}
/>;
return ( return (
<SiteGrid <SiteGrid
containerRef={ref} containerRef={ref}
className={className}
contentMain={ contentMain={
<Link <Link
href={pathForPhoto({ photo })} href={pathForPhoto({ photo })}
@ -143,12 +159,9 @@ export default function PhotoLarge({
{/* Meta */} {/* Meta */}
<div className="pr-2 md:pr-0"> <div className="pr-2 md:pr-0">
<div className="md:relative flex gap-2 items-start"> <div className="md:relative flex gap-2 items-start">
{(photo.title || SHOW_PHOTO_TITLE_FALLBACK_TEXT) && {hasTitle && (showTitleAsH1
<PhotoLink ? <h1>{renderPhotoLink()}</h1>
photo={photo} : renderPhotoLink())}
className="font-bold uppercase flex-grow"
prefetch={prefetch}
/>}
<div className="absolute right-0 translate-y-[-4px] z-10"> <div className="absolute right-0 translate-y-[-4px] z-10">
<AdminPhotoMenuClient {...{ <AdminPhotoMenuClient {...{
photo, photo,
@ -160,7 +173,11 @@ export default function PhotoLarge({
</div> </div>
<div className="space-y-baseline"> <div className="space-y-baseline">
{photo.caption && {photo.caption &&
<div className="uppercase"> <div className={clsx(
'uppercase',
// Prevent collision with admin button
isUserSignedIn && 'md:pr-7',
)}>
{photo.caption} {photo.caption}
</div>} </div>}
{(showCameraContent || showTagsContent) && {(showCameraContent || showTagsContent) &&
@ -226,7 +243,7 @@ export default function PhotoLarge({
photo={photo} photo={photo}
className={clsx( className={clsx(
'text-medium', 'text-medium',
// Prevent date collision with admin button // Prevent collision with admin button
!hasNonDateContent && isUserSignedIn && 'md:pr-7', !hasNonDateContent && isUserSignedIn && 'md:pr-7',
)} )}
/> />

View File

@ -1,13 +1,11 @@
'use client'; 'use client';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Photo, titleForPhoto } from '@/photo'; import { Photo, PhotoSetAttributes, titleForPhoto } from '@/photo';
import Link from 'next/link'; import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems'; import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
export default function PhotoLink({ export default function PhotoLink({
@ -23,16 +21,12 @@ export default function PhotoLink({
children, children,
}: { }: {
photo?: Photo photo?: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
scroll?: boolean scroll?: boolean
prefetch?: boolean prefetch?: boolean
nextPhotoAnimation?: AnimationConfig nextPhotoAnimation?: AnimationConfig
className?: string className?: string
children?: ReactNode children?: ReactNode
}) { } & PhotoSetAttributes) {
const { setNextPhotoAnimation } = useAppState(); const { setNextPhotoAnimation } = useAppState();
return ( return (

View File

@ -1,12 +1,15 @@
'use client'; 'use client';
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.'; import {
Photo,
PhotoSetAttributes,
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
} from '.';
import ImageMedium from '@/components/image/ImageMedium'; import ImageMedium from '@/components/image/ImageMedium';
import Link from 'next/link'; import Link from 'next/link';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react'; import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible'; import useOnVisible from '@/utility/useOnVisible';
@ -24,16 +27,12 @@ export default function PhotoMedium({
onVisible, onVisible,
}: { }: {
photo: Photo photo: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
selected?: boolean selected?: boolean
priority?: boolean priority?: boolean
prefetch?: boolean prefetch?: boolean
className?: string className?: string
onVisible?: () => void onVisible?: () => void
}) { } & PhotoSetAttributes) {
const ref = useRef<HTMLAnchorElement>(null); const ref = useRef<HTMLAnchorElement>(null);
useOnVisible(ref, onVisible); useOnVisible(ref, onVisible);

View File

@ -1,35 +1,38 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo'; import {
Photo,
PhotoSetAttributes,
getNextPhoto,
getPreviousPhoto,
} from '@/photo';
import PhotoLink from './PhotoLink'; import PhotoLink from './PhotoLink';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import { Camera } from '@/camera'; import { clsx } from 'clsx/lite';
import { FilmSimulation } from '@/simulation'; import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
const LISTENER_KEYUP = 'keyup'; const LISTENER_KEYUP = 'keyup';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 }; const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 }; const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
export default function PhotoLinks({ export default function PhotoPrevNext({
photo, photo,
photos, photos = [],
className,
tag, tag,
camera, camera,
simulation, simulation,
focal, focal,
}: { }: {
photo: Photo photo?: Photo
photos: Photo[] photos?: Photo[]
tag?: string className?: string
camera?: Camera } & PhotoSetAttributes) {
simulation?: FilmSimulation
focal?: number
}) {
const router = useRouter(); const router = useRouter();
const { const {
@ -37,8 +40,8 @@ export default function PhotoLinks({
shouldRespondToKeyboardCommands, shouldRespondToKeyboardCommands,
} = useAppState(); } = useAppState();
const previousPhoto = getPreviousPhoto(photo, photos); const previousPhoto = photo ? getPreviousPhoto(photo, photos) : undefined;
const nextPhoto = getNextPhoto(photo, photos); const nextPhoto = photo ? getNextPhoto(photo, photos) : undefined;
useEffect(() => { useEffect(() => {
if (shouldRespondToKeyboardCommands) { if (shouldRespondToKeyboardCommands) {
@ -94,31 +97,47 @@ export default function PhotoLinks({
]); ]);
return ( return (
<> <div className={clsx(
<PhotoLink 'flex items-center',
photo={previousPhoto} className,
nextPhotoAnimation={ANIMATION_RIGHT} )}>
tag={tag} <div className="flex items-center gap-2 select-none">
camera={camera} <PhotoLink
simulation={simulation} photo={previousPhoto}
focal={focal} className="select-none h-[1rem]"
scroll={false} nextPhotoAnimation={ANIMATION_RIGHT}
prefetch tag={tag}
> camera={camera}
PREV simulation={simulation}
</PhotoLink> focal={focal}
<PhotoLink scroll={false}
photo={nextPhoto} prefetch
nextPhotoAnimation={ANIMATION_LEFT} >
tag={tag} <FiChevronLeft
camera={camera} className="sm:hidden text-[1.1rem] translate-y-[-1px]"
simulation={simulation} />
focal={focal} <span className="hidden sm:inline-block">PREV</span>
scroll={false} </PhotoLink>
prefetch <span className="text-extra-extra-dim">
> /
NEXT </span>
</PhotoLink> <PhotoLink
</> photo={nextPhoto}
className="select-none h-[1rem]"
nextPhotoAnimation={ANIMATION_LEFT}
tag={tag}
camera={camera}
simulation={simulation}
focal={focal}
scroll={false}
prefetch
>
<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 PhotoOGTile from '@/photo/PhotoOGTile';
import { absolutePathForPhoto, pathForPhoto } from '@/site/paths'; import { absolutePathForPhoto, pathForPhoto } from '@/site/paths';
import { Photo } from '.'; import { Photo, PhotoSetAttributes } from '.';
import ShareModal from '@/components/ShareModal'; import ShareModal from '@/components/ShareModal';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
export default function PhotoShareModal(props: { export default function PhotoShareModal(props: {
photo: Photo photo: Photo
tag?: string } & PhotoSetAttributes) {
camera?: Camera
simulation?: FilmSimulation
focal?: number
}) {
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForPhoto(props)} 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 ImageSmall from '@/components/image/ImageSmall';
import Link from 'next/link'; import Link from 'next/link';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
@ -6,8 +11,6 @@ import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react'; import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible'; import useOnVisible from '@/utility/useOnVisible';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
export default function PhotoSmall({ export default function PhotoSmall({
photo, photo,
@ -21,15 +24,11 @@ export default function PhotoSmall({
onVisible, onVisible,
}: { }: {
photo: Photo photo: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
selected?: boolean selected?: boolean
className?: string className?: string
prefetch?: boolean prefetch?: boolean
onVisible?: () => void onVisible?: () => void
}) { } & PhotoSetAttributes) {
const ref = useRef<HTMLAnchorElement>(null); const ref = useRef<HTMLAnchorElement>(null);
useOnVisible(ref, onVisible); useOnVisible(ref, onVisible);

View File

@ -35,7 +35,7 @@ import {
} from '@/site/paths'; } from '@/site/paths';
import { blurImageFromUrl, extractImageDataFromBlobPath } from './server'; import { blurImageFromUrl, extractImageDataFromBlobPath } from './server';
import { TAG_FAVS, isTagFavs } from '@/tag'; import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert } from '.'; import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth'; import { runAuthenticatedAdminServerAction } from '@/auth';
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai'; import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai'; import { streamOpenAiImageQuery } from '@/services/openai';
@ -412,6 +412,9 @@ export const getPhotosCachedAction = async (options: GetPhotosOptions) =>
// Public actions // Public actions
export const queryPhotosByTitleAction = async (query: string) => export const searchPhotosAction = async (query: string) =>
(await getPhotos({ query, limit: 10 })) getPhotos({ query, limit: 10 })
.filter(({ title }) => Boolean(title)); .catch(e => {
console.error('Could not query photos', e);
return [] as Photo[];
});

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 { PRIORITY_ORDER_ENABLED } from '@/site/config';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { PhotoSetAttributes } from '..';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
@ -12,16 +10,11 @@ export type GetPhotosOptions = {
limit?: number limit?: number
offset?: number offset?: number
query?: string query?: string
tag?: string
camera?: Camera
lens?: Lens
simulation?: FilmSimulation
focal?: number
takenBefore?: Date takenBefore?: Date
takenAfterInclusive?: Date takenAfterInclusive?: Date
updatedBefore?: Date updatedBefore?: Date
hidden?: 'exclude' | 'include' | 'only' hidden?: 'exclude' | 'include' | 'only'
}; } & PhotoSetAttributes;
export const areOptionsSensitive = (options: GetPhotosOptions) => export const areOptionsSensitive = (options: GetPhotosOptions) =>
options.hidden === 'include' || options.hidden === 'only'; options.hidden === 'include' || options.hidden === 'only';

View File

@ -119,7 +119,10 @@ const safelyQueryPhotos = async <T>(
throw e; throw e;
} }
} else { } else {
console.log(`sql get error: ${e.message} `); if (e.message !== 'The server does not support SSL connections') {
// Avoid re-logging errors on initial installation
console.log(`sql get error: ${e.message} `);
}
throw e; throw e;
} }
} }

View File

@ -1,9 +1,11 @@
import { Camera } from '@/camera';
import { formatFocalLength } from '@/focal'; import { formatFocalLength } from '@/focal';
import { Lens } from '@/lens';
import { getNextImageUrlForRequest } from '@/services/next-image'; import { getNextImageUrlForRequest } from '@/services/next-image';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
import { HIGH_DENSITY_GRID, SHOW_EXIF_DATA } from '@/site/config'; import { HIGH_DENSITY_GRID, SHOW_EXIF_DATA } from '@/site/config';
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths'; import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths';
import { formatDateFromPostgresString } from '@/utility/date'; import { formatDate, formatDateFromPostgresString } from '@/utility/date';
import { import {
formatAperture, formatAperture,
formatIso, formatIso,
@ -99,6 +101,14 @@ export interface Photo extends PhotoDb {
takenAtNaiveFormatted: string 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 => { export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
const photoDb = camelcaseKeys( const photoDb = camelcaseKeys(
photoDbRaw as unknown as Record<string, unknown> photoDbRaw as unknown as Record<string, unknown>
@ -188,8 +198,18 @@ const PHOTO_ID_FORWARDING_TABLE: Record<string, string> = JSON.parse(
export const translatePhotoId = (id: string) => export const translatePhotoId = (id: string) =>
PHOTO_ID_FORWARDING_TABLE[id] || id; PHOTO_ID_FORWARDING_TABLE[id] || id;
export const titleForPhoto = (photo: Photo) => export const titleForPhoto = (
photo.title || 'Untitled'; photo: Photo,
preferDateOverUntitled?: boolean,
) => {
if (photo.title) {
return photo.title;
} else if (preferDateOverUntitled && (photo.takenAt || photo.createdAt)) {
return formatDate(photo.takenAt || photo.createdAt, 'tiny');
} else {
return 'Untitled';
}
};
export const altTextForPhoto = (photo: Photo) => export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(photo); photo.semanticDescription || titleForPhoto(photo);

View File

@ -1,7 +1,7 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { FilmSimulation, descriptionForFilmSimulationPhotos } from '.'; import { FilmSimulation, descriptionForFilmSimulationPhotos } from '.';
import { pathForFilmSimulationShare } from '@/site/paths'; import { pathForFilmSimulationShare } from '@/site/paths';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFilmSimulation from import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation'; '@/simulation/PhotoFilmSimulation';
@ -21,9 +21,9 @@ export default function FilmSimulationHeader({
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
return ( return (
<PhotoSetHeader <PhotoHeader
simulation={simulation}
entity={<PhotoFilmSimulation {...{ simulation }} />} entity={<PhotoFilmSimulation {...{ simulation }} />}
entityVerb="Photo"
entityDescription={descriptionForFilmSimulationPhotos( entityDescription={descriptionForFilmSimulationPhotos(
photos, undefined, count, dateRange)} photos, undefined, count, dateRange)}
photos={photos} photos={photos}

View File

@ -59,7 +59,6 @@ export default function SiteChecklistClient({
arePhotosMatted, arePhotosMatted,
isBlurEnabled, isBlurEnabled,
isGeoPrivacyEnabled, isGeoPrivacyEnabled,
showPhotoTitleFallbackText,
isPriorityOrderEnabled, isPriorityOrderEnabled,
isAiTextGenerationEnabled, isAiTextGenerationEnabled,
aiTextAutoGeneratedFields, aiTextAutoGeneratedFields,
@ -508,15 +507,6 @@ export default function SiteChecklistClient({
collection/display of location-based data: collection/display of location-based data:
{renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])} {renderEnvVars(['NEXT_PUBLIC_GEO_PRIVACY'])}
</ChecklistRow> </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 <ChecklistRow
title="Priority order" title="Priority order"
status={isPriorityOrderEnabled} status={isPriorityOrderEnabled}

View File

@ -139,8 +139,6 @@ export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1'; process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
export const GEO_PRIVACY_ENABLED = export const GEO_PRIVACY_ENABLED =
process.env.NEXT_PUBLIC_GEO_PRIVACY === '1'; 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 = export const AI_TEXT_GENERATION_ENABLED =
Boolean(process.env.OPENAI_SECRET_KEY); Boolean(process.env.OPENAI_SECRET_KEY);
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText( export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsText(
@ -217,7 +215,6 @@ export const CONFIG_CHECKLIST_STATUS = {
arePhotosMatted: MATTE_PHOTOS, arePhotosMatted: MATTE_PHOTOS,
isBlurEnabled: BLUR_ENABLED, isBlurEnabled: BLUR_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED, isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
showPhotoTitleFallbackText: SHOW_PHOTO_TITLE_FALLBACK_TEXT,
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED, isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0 ? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0

View File

@ -142,6 +142,10 @@
@apply @apply
text-gray-400/80 dark:text-gray-400/50 text-gray-400/80 dark:text-gray-400/50
} }
.text-extra-extra-dim {
@apply
text-gray-200 dark:text-gray-800
}
.text-icon { .text-icon {
@apply @apply
text-gray-800 dark:text-gray-200 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 { BASE_URL, GRID_HOMEPAGE_ENABLED } from './config';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';
@ -75,13 +75,7 @@ export const PATHS_TO_CACHE = [
...PATHS_ADMIN, ...PATHS_ADMIN,
]; ];
interface PhotoPathParams { type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetAttributes;
photo: PhotoOrPhotoId
tag?: string
camera?: Camera
simulation?: FilmSimulation
focal?: number
}
// Absolute paths // Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`; export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
@ -280,11 +274,7 @@ export const isPathProtected = (pathname?: string) =>
export const getPathComponents = (pathname = ''): { export const getPathComponents = (pathname = ''): {
photoId?: string photoId?: string
tag?: string } & PhotoSetAttributes => {
camera?: Camera
simulation?: FilmSimulation
focal?: number
} => {
const photoIdFromPhoto = pathname.match( const photoIdFromPhoto = pathname.match(
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
const photoIdFromTag = pathname.match( const photoIdFromTag = pathname.match(

View File

@ -27,6 +27,8 @@ export interface AppStateContext {
isPerformingSelectEdit?: boolean isPerformingSelectEdit?: boolean
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>> setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
// DEBUG // DEBUG
isGridHighDensity?: boolean
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
arePhotosMatted?: boolean arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>> setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
shouldDebugImageFallbacks?: boolean shouldDebugImageFallbacks?: boolean

View File

@ -6,7 +6,7 @@ import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames'; import usePathnames from '@/utility/usePathnames';
import { getAuthAction } from '@/auth/actions'; import { getAuthAction } from '@/auth/actions';
import useSWR from 'swr'; import useSWR from 'swr';
import { MATTE_PHOTOS } from '@/site/config'; import { HIGH_DENSITY_GRID, MATTE_PHOTOS } from '@/site/config';
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions'; import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
export default function AppStateProvider({ export default function AppStateProvider({
@ -39,6 +39,8 @@ export default function AppStateProvider({
const [isPerformingSelectEdit, setIsPerformingSelectEdit] = const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
useState(false); useState(false);
// DEBUG // DEBUG
const [isGridHighDensity, setIsGridHighDensity] =
useState(HIGH_DENSITY_GRID);
const [arePhotosMatted, setArePhotosMatted] = const [arePhotosMatted, setArePhotosMatted] =
useState(MATTE_PHOTOS); useState(MATTE_PHOTOS);
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] = const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
@ -101,6 +103,8 @@ export default function AppStateProvider({
isPerformingSelectEdit, isPerformingSelectEdit,
setIsPerformingSelectEdit, setIsPerformingSelectEdit,
// DEBUG // DEBUG
isGridHighDensity,
setIsGridHighDensity,
arePhotosMatted, arePhotosMatted,
setArePhotosMatted, setArePhotosMatted,
shouldDebugImageFallbacks, shouldDebugImageFallbacks,

View File

@ -1,5 +1,5 @@
import { Photo, photoQuantityText } from '@/photo'; import { Photo, photoQuantityText } from '@/photo';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import HiddenTag from './HiddenTag'; import HiddenTag from './HiddenTag';
export default function HiddenHeader({ export default function HiddenHeader({
@ -14,7 +14,7 @@ export default function HiddenHeader({
count: number count: number
}) { }) {
return ( return (
<PhotoSetHeader <PhotoHeader
key="HiddenHeader" key="HiddenHeader"
entity={<HiddenTag contrast="high" />} entity={<HiddenTag contrast="high" />}
entityDescription={photoQuantityText(count, false)} entityDescription={photoQuantityText(count, false)}

View File

@ -2,7 +2,7 @@ import { Photo, PhotoDateRange } from '@/photo';
import PhotoTag from './PhotoTag'; import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos, isTagFavs } from '.'; import { descriptionForTaggedPhotos, isTagFavs } from '.';
import { pathForTagShare } from '@/site/paths'; import { pathForTagShare } from '@/site/paths';
import PhotoSetHeader from '@/photo/PhotoSetHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import FavsTag from './FavsTag'; import FavsTag from './FavsTag';
export default function TagHeader({ export default function TagHeader({
@ -21,7 +21,8 @@ export default function TagHeader({
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
return ( return (
<PhotoSetHeader <PhotoHeader
tag={tag}
entity={isTagFavs(tag) entity={isTagFavs(tag)
? <FavsTag contrast="high" /> ? <FavsTag contrast="high" />
: <PhotoTag tag={tag} contrast="high" />} : <PhotoTag tag={tag} contrast="high" />}

View File

@ -1,5 +1,6 @@
import { format, parseISO, parse } from 'date-fns'; 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_SHORT = 'dd MMM yyyy';
const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma'; const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma';
const DATE_STRING_FORMAT = 'dd MMM yyyy 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 AmbiguousTimestamp = number | string;
type Length = 'short' | 'medium' | 'long'; type Length = 'tiny' | 'short' | 'medium' | 'long';
export const formatDate = (date: Date, length: Length = 'long') => { export const formatDate = (date: Date, length: Length = 'long') => {
switch (length) { switch (length) {
case 'tiny':
return format(date, DATE_STRING_FORMAT_TINY);
case 'short': case 'short':
return format(date, DATE_STRING_FORMAT_SHORT); return format(date, DATE_STRING_FORMAT_SHORT);
case 'medium': case 'medium':

12
src/utility/html.ts Normal file
View File

@ -0,0 +1,12 @@
import sanitizeHtml from 'sanitize-html';
const ALLOWED_FORMATTING_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br'];
export const safelyParseFormattedHtml = (text: string) =>
sanitizeHtml(text, {
allowedTags: ALLOWED_FORMATTING_TAGS,
});
// Matches two or more <br> or <br /> tags in a row
export const htmlHasBrParagraphBreaks = (text: string) =>
text.match(/(<br\s*\/?>){2}/i);