Merge branch 'main' into add-public-downloads
This commit is contained in:
commit
9ad7f89dfb
@ -2,7 +2,7 @@
|
||||
|
||||
https://github.com/sambecker/exif-photo-blog/assets/169298/4253ea54-558a-4358-8834-89943cfbafb4
|
||||
|
||||
[](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)
|
||||
[](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
|
||||
-
|
||||
@ -98,8 +98,8 @@ Application behavior can be changed by configuring the following environment var
|
||||
|
||||
#### Site meta
|
||||
- `NEXT_PUBLIC_SITE_TITLE` (seen in browser tab)
|
||||
- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, under title)
|
||||
- `NEXT_PUBLIC_SITE_ABOUT` (e.g., seen in grid sidebar)
|
||||
- `NEXT_PUBLIC_SITE_DESCRIPTION` (seen in nav, beneath title)
|
||||
- `NEXT_PUBLIC_SITE_ABOUT` (seen in grid sidebar—accepted rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
|
||||
|
||||
#### Site behavior
|
||||
- `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_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_ALLOW_PUBLIC_DOWNLOADS = 1` enables public image downloads
|
||||
|
||||
17
__tests__/html.test.ts
Normal file
17
__tests__/html.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
48
package.json
48
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exif-photo-blog",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -9,54 +9,56 @@
|
||||
"analyze": "ANALYZE=true next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^0.0.53",
|
||||
"@aws-sdk/client-s3": "3.637.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.637.0",
|
||||
"@next/bundle-analyzer": "14.2.6",
|
||||
"@ai-sdk/openai": "^0.0.61",
|
||||
"@aws-sdk/client-s3": "3.654.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.654.0",
|
||||
"@next/bundle-analyzer": "14.2.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.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/react": "^16.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/react": "18.3.4",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "18.3.8",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^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/blob": "^0.23.4",
|
||||
"@vercel/blob": "^0.24.0",
|
||||
"@vercel/kv": "^2.0.0",
|
||||
"@vercel/speed-insights": "^1.0.12",
|
||||
"ai": "^3.3.17",
|
||||
"ai": "^3.4.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.6",
|
||||
"eslint-config-next": "14.2.13",
|
||||
"exifr": "^7.1.3",
|
||||
"framer-motion": "^11.3.30",
|
||||
"framer-motion": "^11.5.6",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "^14.2.6",
|
||||
"next-auth": "5.0.0-beta.19",
|
||||
"next": "14.2.13",
|
||||
"next-auth": "5.0.0-beta.21",
|
||||
"next-themes": "^0.3.0",
|
||||
"pg": "^8.12.0",
|
||||
"postcss": "8.4.41",
|
||||
"pg": "^8.13.0",
|
||||
"postcss": "8.4.47",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"sharp": "^0.33.5",
|
||||
"sonner": "^1.5.0",
|
||||
"swr": "^2.2.5",
|
||||
"tailwindcss": "3.4.10",
|
||||
"tailwindcss": "3.4.12",
|
||||
"ts-exif-parser": "^0.2.2",
|
||||
"typescript": "5.5.4",
|
||||
"typescript": "5.6.2",
|
||||
"undici": "^6.19.8",
|
||||
"use-debounce": "^10.0.3"
|
||||
}
|
||||
|
||||
2366
pnpm-lock.yaml
generated
2366
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@ import { getUniqueTagsCached } from '@/photo/cache';
|
||||
import AdminBatchEditPanelClient from './AdminBatchEditPanelClient';
|
||||
|
||||
export default async function AdminBatchEditPanel() {
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
const uniqueTags = await getUniqueTagsCached().catch(() => []);
|
||||
return (
|
||||
<AdminBatchEditPanelClient {...{ uniqueTags }} />
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -31,7 +31,7 @@ import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { IoInvertModeSharp } from 'react-icons/io5';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { queryPhotosByTitleAction } from '@/photo/actions';
|
||||
import { searchPhotosAction } from '@/photo/actions';
|
||||
import { RiToolsFill } from 'react-icons/ri';
|
||||
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
|
||||
import { HiDocumentText } from 'react-icons/hi';
|
||||
@ -86,12 +86,14 @@ export default function CommandKClient({
|
||||
hiddenPhotosCount,
|
||||
selectedPhotoIds,
|
||||
setSelectedPhotoIds,
|
||||
isGridHighDensity,
|
||||
arePhotosMatted,
|
||||
shouldShowBaselineGrid,
|
||||
shouldDebugImageFallbacks,
|
||||
setIsCommandKOpen: setIsOpen,
|
||||
setShouldRespondToKeyboardCommands,
|
||||
setShouldShowBaselineGrid,
|
||||
setIsGridHighDensity,
|
||||
setArePhotosMatted,
|
||||
setShouldDebugImageFallbacks,
|
||||
} = useAppState();
|
||||
@ -149,27 +151,33 @@ export default function CommandKClient({
|
||||
useEffect(() => {
|
||||
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH && !isPending) {
|
||||
setIsLoading(true);
|
||||
queryPhotosByTitleAction(queryDebounced).then(photos => {
|
||||
if (isOpenRef.current) {
|
||||
setQueriedSections(photos.length > 0
|
||||
? [{
|
||||
heading: 'Photos',
|
||||
accessory: <TbPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
label: titleForPhoto(photo),
|
||||
keywords: getKeywordsForPhoto(photo),
|
||||
annotation: <PhotoDate {...{ photo }} />,
|
||||
accessory: <PhotoSmall photo={photo} />,
|
||||
path: pathForPhoto({ photo }),
|
||||
})),
|
||||
}]
|
||||
: []);
|
||||
} else {
|
||||
// Ignore stale requests that come in after dialog is closed
|
||||
searchPhotosAction(queryDebounced)
|
||||
.then(photos => {
|
||||
if (isOpenRef.current) {
|
||||
setQueriedSections(photos.length > 0
|
||||
? [{
|
||||
heading: 'Photos',
|
||||
accessory: <TbPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
label: titleForPhoto(photo),
|
||||
keywords: getKeywordsForPhoto(photo),
|
||||
annotation: <PhotoDate {...{ photo }} />,
|
||||
accessory: <PhotoSmall photo={photo} />,
|
||||
path: pathForPhoto({ photo }),
|
||||
})),
|
||||
}]
|
||||
: []);
|
||||
} else {
|
||||
// Ignore stale requests that come in after dialog is closed
|
||||
setQueriedSections([]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
setQueriedSections([]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryDebounced, isPending]);
|
||||
|
||||
@ -240,6 +248,10 @@ export default function CommandKClient({
|
||||
label: 'Toggle Photo Matting',
|
||||
action: () => setArePhotosMatted?.(prev => !prev),
|
||||
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',
|
||||
action: () => setShouldDebugImageFallbacks?.(prev => !prev),
|
||||
|
||||
@ -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,77 +28,69 @@ 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
|
||||
}) {
|
||||
} & 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 (
|
||||
<div>
|
||||
{tag &&
|
||||
<SiteGrid
|
||||
className="mt-4 mb-8"
|
||||
contentMain={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}
|
||||
/>}
|
||||
/>}
|
||||
{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}
|
||||
/>}
|
||||
<SiteGrid
|
||||
className="mt-1.5 mb-6"
|
||||
contentMain={customHeader ?? <PhotoHeader
|
||||
selectedPhoto={photo}
|
||||
photos={photos}
|
||||
/>}
|
||||
/>
|
||||
<AnimateItems
|
||||
className="md:mb-8"
|
||||
animateFromAppState
|
||||
@ -112,6 +101,8 @@ export default function PhotoDetailPage({
|
||||
primaryTag={tag}
|
||||
priority
|
||||
prefetchRelatedLinks
|
||||
showTitle={Boolean(customHeader)}
|
||||
showTitleAsH1
|
||||
showCamera={!camera}
|
||||
showSimulation={!simulation}
|
||||
shouldShare={shouldShare}
|
||||
@ -134,30 +125,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,12 +1,10 @@
|
||||
'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 { GRID_ASPECT_RATIO } 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,11 +40,12 @@ export default function PhotoGrid({
|
||||
canSelect?: boolean
|
||||
onLastPhotoVisible?: () => void
|
||||
onAnimationComplete?: () => void
|
||||
}) {
|
||||
} & PhotoSetAttributes) {
|
||||
const {
|
||||
isUserSignedIn,
|
||||
selectedPhotoIds,
|
||||
setSelectedPhotoIds,
|
||||
isGridHighDensity,
|
||||
} = useAppState();
|
||||
|
||||
return (
|
||||
@ -59,7 +54,7 @@ export default function PhotoGrid({
|
||||
'grid gap-0.5 sm:gap-1',
|
||||
small
|
||||
? '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 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4',
|
||||
'items-center',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -16,6 +16,8 @@ import { useAppState } from '@/state/AppState';
|
||||
import { useMemo } from 'react';
|
||||
import HiddenTag from '@/tag/HiddenTag';
|
||||
import { SITE_ABOUT } from '@/site/config';
|
||||
import { htmlHasBrParagraphBreaks, safelyParseFormattedHtml } from '@/utility/html';
|
||||
import { clsx } from 'clsx/lite';
|
||||
|
||||
export default function PhotoGridSidebar({
|
||||
tags,
|
||||
@ -43,10 +45,14 @@ export default function PhotoGridSidebar({
|
||||
{SITE_ABOUT && <HeaderList
|
||||
items={[<p
|
||||
key="about"
|
||||
className="max-w-60 normal-case text-main"
|
||||
>
|
||||
{SITE_ABOUT}
|
||||
</p>]}
|
||||
className={clsx(
|
||||
'max-w-60 normal-case text-main',
|
||||
htmlHasBrParagraphBreaks(SITE_ABOUT) && 'pb-2',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safelyParseFormattedHtml(SITE_ABOUT),
|
||||
}}
|
||||
/>]}
|
||||
/>}
|
||||
{tags.length > 0 && <HeaderList
|
||||
title='Tags'
|
||||
|
||||
168
src/photo/PhotoHeader.tsx
Normal file
168
src/photo/PhotoHeader.tsx
Normal 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>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -28,7 +28,6 @@ import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import {
|
||||
SHOULD_PREFETCH_ALL_LINKS,
|
||||
SHOW_PHOTO_TITLE_FALLBACK_TEXT,
|
||||
ALLOW_PUBLIC_DOWNLOADS,
|
||||
} from '@/site/config';
|
||||
import AdminPhotoMenuClient from '@/admin/AdminPhotoMenuClient';
|
||||
@ -40,11 +39,14 @@ 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,
|
||||
showTitleAsH1,
|
||||
showCamera = true,
|
||||
showSimulation = true,
|
||||
shouldShare = true,
|
||||
@ -57,11 +59,14 @@ export default function PhotoLarge({
|
||||
onVisible,
|
||||
}: {
|
||||
photo: Photo
|
||||
className?: string
|
||||
primaryTag?: string
|
||||
priority?: boolean
|
||||
prefetch?: boolean
|
||||
prefetchRelatedLinks?: boolean
|
||||
revalidatePhoto?: RevalidatePhoto
|
||||
showTitle?: boolean
|
||||
showTitleAsH1?: boolean
|
||||
showCamera?: boolean
|
||||
showSimulation?: boolean
|
||||
shouldShare?: boolean
|
||||
@ -87,10 +92,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 ||
|
||||
@ -101,9 +109,17 @@ export default function PhotoLarge({
|
||||
hasTitleContent ||
|
||||
hasMetaContent;
|
||||
|
||||
const renderPhotoLink = () =>
|
||||
<PhotoLink
|
||||
photo={photo}
|
||||
className="font-bold uppercase flex-grow"
|
||||
prefetch={prefetch}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<SiteGrid
|
||||
containerRef={ref}
|
||||
className={className}
|
||||
contentMain={
|
||||
<Link
|
||||
href={pathForPhoto({ photo })}
|
||||
@ -143,12 +159,9 @@ 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) &&
|
||||
<PhotoLink
|
||||
photo={photo}
|
||||
className="font-bold uppercase flex-grow"
|
||||
prefetch={prefetch}
|
||||
/>}
|
||||
{hasTitle && (showTitleAsH1
|
||||
? <h1>{renderPhotoLink()}</h1>
|
||||
: renderPhotoLink())}
|
||||
<div className="absolute right-0 translate-y-[-4px] z-10">
|
||||
<AdminPhotoMenuClient {...{
|
||||
photo,
|
||||
@ -160,7 +173,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) &&
|
||||
@ -226,7 +243,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,31 +97,47 @@ export default function PhotoLinks({
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoLink
|
||||
photo={previousPhoto}
|
||||
nextPhotoAnimation={ANIMATION_RIGHT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
simulation={simulation}
|
||||
focal={focal}
|
||||
scroll={false}
|
||||
prefetch
|
||||
>
|
||||
PREV
|
||||
</PhotoLink>
|
||||
<PhotoLink
|
||||
photo={nextPhoto}
|
||||
nextPhotoAnimation={ANIMATION_LEFT}
|
||||
tag={tag}
|
||||
camera={camera}
|
||||
simulation={simulation}
|
||||
focal={focal}
|
||||
scroll={false}
|
||||
prefetch
|
||||
>
|
||||
NEXT
|
||||
</PhotoLink>
|
||||
</>
|
||||
<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}
|
||||
simulation={simulation}
|
||||
focal={focal}
|
||||
scroll={false}
|
||||
prefetch
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -35,7 +35,7 @@ import {
|
||||
} from '@/site/paths';
|
||||
import { blurImageFromUrl, extractImageDataFromBlobPath } from './server';
|
||||
import { TAG_FAVS, isTagFavs } from '@/tag';
|
||||
import { convertPhotoToPhotoDbInsert } from '.';
|
||||
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
||||
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
||||
import { streamOpenAiImageQuery } from '@/services/openai';
|
||||
@ -412,6 +412,9 @@ export const getPhotosCachedAction = async (options: GetPhotosOptions) =>
|
||||
|
||||
// Public actions
|
||||
|
||||
export const queryPhotosByTitleAction = async (query: string) =>
|
||||
(await getPhotos({ query, limit: 10 }))
|
||||
.filter(({ title }) => Boolean(title));
|
||||
export const searchPhotosAction = async (query: string) =>
|
||||
getPhotos({ query, limit: 10 })
|
||||
.catch(e => {
|
||||
console.error('Could not query photos', e);
|
||||
return [] as Photo[];
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -119,7 +119,10 @@ const safelyQueryPhotos = async <T>(
|
||||
throw e;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
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';
|
||||
import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths';
|
||||
import { formatDateFromPostgresString } from '@/utility/date';
|
||||
import { formatDate, formatDateFromPostgresString } from '@/utility/date';
|
||||
import {
|
||||
formatAperture,
|
||||
formatIso,
|
||||
@ -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>
|
||||
@ -188,8 +198,18 @@ const PHOTO_ID_FORWARDING_TABLE: Record<string, string> = JSON.parse(
|
||||
export const translatePhotoId = (id: string) =>
|
||||
PHOTO_ID_FORWARDING_TABLE[id] || id;
|
||||
|
||||
export const titleForPhoto = (photo: Photo) =>
|
||||
photo.title || 'Untitled';
|
||||
export const titleForPhoto = (
|
||||
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) =>
|
||||
photo.semanticDescription || titleForPhoto(photo);
|
||||
|
||||
@ -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,
|
||||
@ -508,15 +507,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(
|
||||
@ -217,7 +215,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(
|
||||
|
||||
@ -27,6 +27,8 @@ export interface AppStateContext {
|
||||
isPerformingSelectEdit?: boolean
|
||||
setIsPerformingSelectEdit?: Dispatch<SetStateAction<boolean>>
|
||||
// DEBUG
|
||||
isGridHighDensity?: boolean
|
||||
setIsGridHighDensity?: Dispatch<SetStateAction<boolean>>
|
||||
arePhotosMatted?: boolean
|
||||
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugImageFallbacks?: boolean
|
||||
|
||||
@ -6,7 +6,7 @@ import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import usePathnames from '@/utility/usePathnames';
|
||||
import { getAuthAction } from '@/auth/actions';
|
||||
import useSWR from 'swr';
|
||||
import { MATTE_PHOTOS } from '@/site/config';
|
||||
import { HIGH_DENSITY_GRID, MATTE_PHOTOS } from '@/site/config';
|
||||
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
|
||||
|
||||
export default function AppStateProvider({
|
||||
@ -39,6 +39,8 @@ export default function AppStateProvider({
|
||||
const [isPerformingSelectEdit, setIsPerformingSelectEdit] =
|
||||
useState(false);
|
||||
// DEBUG
|
||||
const [isGridHighDensity, setIsGridHighDensity] =
|
||||
useState(HIGH_DENSITY_GRID);
|
||||
const [arePhotosMatted, setArePhotosMatted] =
|
||||
useState(MATTE_PHOTOS);
|
||||
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
|
||||
@ -101,6 +103,8 @@ export default function AppStateProvider({
|
||||
isPerformingSelectEdit,
|
||||
setIsPerformingSelectEdit,
|
||||
// DEBUG
|
||||
isGridHighDensity,
|
||||
setIsGridHighDensity,
|
||||
arePhotosMatted,
|
||||
setArePhotosMatted,
|
||||
shouldDebugImageFallbacks,
|
||||
|
||||
@ -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':
|
||||
|
||||
12
src/utility/html.ts
Normal file
12
src/utility/html.ts
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user