diff --git a/.vscode/settings.json b/.vscode/settings.json index e91d08d0..eef74eea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "Hasselblad", "headlessui", "hgetall", + "Hoverable", "hset", "IIIA", "ILCE", diff --git a/README.md b/README.md index 69d3f6b2..ff3861b3 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content - `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links - `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) +- `NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS = 1` shows images when hovering over category links like cameras and lenses (⚠️ setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES = 1` strongly recommended for responsive hover interactions) - `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls - `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta - `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal diff --git a/app/focal/[focal]/image/route.tsx b/app/focal/[focal]/image/route.tsx index 11076633..0e3ed652 100644 --- a/app/focal/[focal]/image/route.tsx +++ b/app/focal/[focal]/image/route.tsx @@ -17,7 +17,7 @@ export const generateStaticParams = staticallyGenerateCategoryIfConfigured( 'image', getUniqueFocalLengths, focalLengths => focalLengths - .map(({ focal }) => ({ focal: formatFocalLength(focal)! })), + .map(({ focal }) => ({ focal: formatFocalLength(focal) })), ); export async function GET( diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index 3a972d06..4e0a290a 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -92,6 +92,7 @@ export default function AdminAppConfigurationClient({ collapseSidebarCategories, showKeyboardShortcutTooltips, showExifInfo, + showCategoryImageHover, showZoomControls, showTakenAtTimeHidden, showSocial, @@ -647,6 +648,25 @@ export default function AdminAppConfigurationClient({ Set environment variable to {'"1"'} to hide EXIF data: {renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])} + +
+
+ Set environment variable to {'"1"'} to show images when hovering + over category links like cameras and lenses: + {renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])} +
+
+ Static optimization strongly recommended + for responsive hover interactions: + {/* eslint-disable-next-line max-len */} + {renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])} +
+
+
- `${PREFIX_TAG}/${tag}`; - export const pathForCamera = ({ make, model }: Camera) => `${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`; -export const pathForFilm = (film: string) => - `${PREFIX_FILM}/${film}`; - export const pathForLens = ({ make, model }: Lens) => make ? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}` : `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`; -export const pathForFocalLength = (focal: number) => - `${PREFIX_FOCAL_LENGTH}/${focal}mm`; +export const pathForTag = (tag: string) => + `${PREFIX_TAG}/${tag}`; export const pathForRecipe = (recipe: string) => `${PREFIX_RECIPE}/${recipe}`; +export const pathForFilm = (film: string) => + `${PREFIX_FILM}/${film}`; + +export const pathForFocalLength = (focal: number) => + `${PREFIX_FOCAL_LENGTH}/${focal}mm`; + +// Image paths +const pathForImage = (path: string) => + `${path}/${IMAGE}`; + +export const pathForPhotoImage = (photo: PhotoOrPhotoId) => + pathForImage(pathForPhoto({ photo })); + +export const pathForCameraImage = (camera: Camera) => + pathForImage(pathForCamera(camera)); + +export const pathForLensImage = (lens: Lens) => + pathForImage(pathForLens(lens)); + +export const pathForTagImage = (tag: string) => + pathForImage(pathForTag(tag)); + +export const pathForRecipeImage = (recipe: string) => + pathForImage(pathForRecipe(recipe)); + +export const pathForFilmImage = (film: string) => + pathForImage(pathForFilm(film)); + +export const pathForFocalLengthImage = (focal: number) => + pathForImage(pathForFocalLength(focal)); + // Absolute paths export const ABSOLUTE_PATH_FOR_FEED_JSON = `${getBaseUrl()}${PATH_FEED_JSON}`; @@ -188,58 +214,49 @@ export const absolutePathForPhoto = ( ) => `${getBaseUrl(share)}${pathForPhoto(params)}`; -export const absolutePathForTag = (tag: string, share?: boolean) => - `${getBaseUrl(share)}${pathForTag(tag)}`; - export const absolutePathForCamera= (camera: Camera, share?: boolean) => `${getBaseUrl(share)}${pathForCamera(camera)}`; export const absolutePathForLens= (lens: Lens, share?: boolean) => `${getBaseUrl(share)}${pathForLens(lens)}`; -export const absolutePathForFilm = (film: string, share?: boolean) => - `${getBaseUrl(share)}${pathForFilm(film)}`; +export const absolutePathForTag = (tag: string, share?: boolean) => + `${getBaseUrl(share)}${pathForTag(tag)}`; export const absolutePathForRecipe = (recipe: string, share?: boolean) => `${getBaseUrl(share)}${pathForRecipe(recipe)}`; +export const absolutePathForFilm = (film: string, share?: boolean) => + `${getBaseUrl(share)}${pathForFilm(film)}`; + export const absolutePathForFocalLength = (focal: number, share?: boolean) => `${getBaseUrl(share)}${pathForFocalLength(focal)}`; export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) => - `${absolutePathForPhoto({ photo })}/image`; - -export const absolutePathForTagImage = (tag: string) => - `${absolutePathForTag(tag)}/image`; + `${getBaseUrl()}${pathForPhotoImage(photo)}`; export const absolutePathForCameraImage= (camera: Camera) => - `${absolutePathForCamera(camera)}/image`; + `${getBaseUrl()}${pathForCameraImage(camera)}`; export const absolutePathForLensImage= (lens: Lens) => - `${absolutePathForLens(lens)}/image`; + `${getBaseUrl()}${pathForLensImage(lens)}`; -export const absolutePathForFilmImage = (film: string) => - `${absolutePathForFilm(film)}/image`; +export const absolutePathForTagImage = (tag: string) => + `${getBaseUrl()}${pathForTagImage(tag)}`; export const absolutePathForRecipeImage = (recipe: string) => - `${absolutePathForRecipe(recipe)}/image`; + `${getBaseUrl()}${pathForRecipeImage(recipe)}`; -export const absolutePathForFocalLengthImage = - (focal: number) => - `${absolutePathForFocalLength(focal)}/image`; +export const absolutePathForFilmImage = (film: string) => + `${getBaseUrl()}${pathForFilmImage(film)}`; + +export const absolutePathForFocalLengthImage = (focal: number) => + `${getBaseUrl()}${pathForFocalLengthImage(focal)}`; // p/[photoId] export const isPathPhoto = (pathname = '') => new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(pathname); -// tag/[tag] -export const isPathTag = (pathname = '') => - new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname);; - -// tag/[tag]/[photoId] -export const isPathTagPhoto = (pathname = '') => - new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname); - // shot-on/[make]/[model] export const isPathCamera = (pathname = '') => new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname); @@ -248,6 +265,22 @@ export const isPathCamera = (pathname = '') => export const isPathCameraPhoto = (pathname = '') => new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(pathname); +// tag/[tag] +export const isPathTag = (pathname = '') => + new RegExp(`^${PREFIX_TAG}/[^/]+/?$`).test(pathname); + +// tag/[tag]/[photoId] +export const isPathTagPhoto = (pathname = '') => + new RegExp(`^${PREFIX_TAG}/[^/]+/[^/]+/?$`).test(pathname); + +// recipe/[recipe] +export const isPathRecipe = (pathname = '') => + new RegExp(`^${PREFIX_RECIPE}/[^/]+/?$`).test(pathname); + +// recipe/[recipe]/[photoId] +export const isPathRecipePhoto = (pathname = '') => + new RegExp(`^${PREFIX_RECIPE}/[^/]+/[^/]+/?$`).test(pathname); + // film/[film] export const isPathFilm = (pathname = '') => new RegExp(`^${PREFIX_FILM}/[^/]+/?$`).test(pathname); @@ -313,20 +346,20 @@ export const getPathComponents = (pathname = ''): { } & PhotoSetCategory => { const photoIdFromPhoto = pathname.match( new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1]; - const photoIdFromTag = pathname.match( - new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1]; const photoIdFromCamera = pathname.match( new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1]; + const cameraMake = pathname.match( + new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; + const cameraModel = pathname.match( + new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1]; + const photoIdFromTag = pathname.match( + new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1]; const photoIdFromFilm = pathname.match( new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1]; const photoIdFromFocalLength = pathname.match( new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1]; const tag = pathname.match( new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1]; - const cameraMake = pathname.match( - new RegExp(`^${PREFIX_CAMERA}/([^/]+)`))?.[1]; - const cameraModel = pathname.match( - new RegExp(`^${PREFIX_CAMERA}/[^/]+/([^/]+)`))?.[1]; const film = pathname.match( new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string; const focalString = pathname.match( diff --git a/src/camera/CameraHeader.tsx b/src/camera/CameraHeader.tsx index ccca7bea..ec85149c 100644 --- a/src/camera/CameraHeader.tsx +++ b/src/camera/CameraHeader.tsx @@ -21,12 +21,17 @@ export default async function CameraHeader({ count?: number dateRange?: PhotoDateRange }) { - const camera = cameraFromPhoto(photos[0], cameraProp); const appText = await getAppText(); + const camera = cameraFromPhoto(photos[0], cameraProp); + return ( } + entity={} entityDescription={ descriptionForCameraPhotos( photos, diff --git a/src/camera/CameraOGTile.tsx b/src/camera/CameraOGTile.tsx index c03716d5..eeac6d78 100644 --- a/src/camera/CameraOGTile.tsx +++ b/src/camera/CameraOGTile.tsx @@ -1,8 +1,8 @@ 'use client'; import { Photo, PhotoDateRange } from '@/photo'; -import { absolutePathForCameraImage, pathForCamera } from '@/app/paths'; -import OGTile, { OGLoadingState } from '@/components/OGTile'; +import { pathForCamera, pathForCameraImage } from '@/app/paths'; +import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import { Camera } from '.'; import { descriptionForCameraPhotos, titleForCamera } from './meta'; import { useAppText } from '@/i18n/state/client'; @@ -40,7 +40,7 @@ export default function CameraOGTile({ dateRange, ), path: pathForCamera(camera), - pathImageAbsolute: absolutePathForCameraImage(camera), + pathImage: pathForCameraImage(camera), loadingState: loadingStateExternal, onLoad, onFail, diff --git a/src/camera/PhotoCamera.tsx b/src/camera/PhotoCamera.tsx index dc7b1eed..75eafb70 100644 --- a/src/camera/PhotoCamera.tsx +++ b/src/camera/PhotoCamera.tsx @@ -1,11 +1,15 @@ +'use client'; + import { AiFillApple } from 'react-icons/ai'; -import { pathForCamera } from '@/app/paths'; +import { pathForCamera, pathForCameraImage } from '@/app/paths'; import { Camera, formatCameraText } from '.'; import EntityLink, { EntityLinkExternalProps, } from '@/components/primitives/EntityLink'; import IconCamera from '@/components/icons/IconCamera'; import { isCameraApple } from '@/platforms/apple'; +import { useAppText } from '@/i18n/state/client'; +import { photoQuantityText } from '@/photo'; export default function PhotoCamera({ camera, @@ -17,6 +21,7 @@ export default function PhotoCamera({ hideAppleIcon?: boolean countOnHover?: number } & EntityLinkExternalProps) { + const appText = useAppText(); const isApple = isCameraApple(camera); const showAppleIcon = !hideAppleIcon && isApple; @@ -24,7 +29,10 @@ export default function PhotoCamera({ , items: focalLengths.map(({ focal, count }) => ({ - label: formatFocalLength(focal)!, + label: formatFocalLength(focal), annotation: formatCount(count), annotationAria: formatCountDescriptive(count), path: pathForFocalLength(focal), diff --git a/src/components/OGTile.tsx b/src/components/OGTile.tsx deleted file mode 100644 index 7422a1d3..00000000 --- a/src/components/OGTile.tsx +++ /dev/null @@ -1,136 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { clsx } from 'clsx/lite'; -import Link from 'next/link'; -import { BiError } from 'react-icons/bi'; -import Spinner from '@/components/Spinner'; -import { IMAGE_OG_DIMENSION } from '../image-response'; -import useVisible from '@/utility/useVisible'; - -export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; - -export default function OGTile({ - title, - description, - path, - pathImageAbsolute, - loadingState: loadingStateExternal, - riseOnHover, - onLoad, - onFail, - retryTime, - onVisible, -}: { - title: string - description: string - path: string - pathImageAbsolute: string - loadingState?: OGLoadingState - onLoad?: () => void - onFail?: () => void - riseOnHover?: boolean - retryTime?: number - onVisible?: () => void -}) { - const ref = useRef(null); - - const [loadingStateInternal, setLoadingStateInternal] = - useState(loadingStateExternal ?? 'unloaded'); - - const loadingState = loadingStateExternal ?? loadingStateInternal; - - useEffect(() => { - if ( - !loadingStateExternal && - loadingStateInternal === 'unloaded' - ) { - setLoadingStateInternal('loading'); - } - }, [loadingStateExternal, loadingStateInternal]); - - const { width, height, aspectRatio } = IMAGE_OG_DIMENSION; - - useVisible({ ref, onVisible }); - - return ( - -
- {loadingState === 'loading' && -
- -
} - {loadingState === 'failed' && -
- -
} - {(loadingState === 'loading' || loadingState === 'loaded') && - {title} { - if (onLoad) { - onLoad(); - } else { - setLoadingStateInternal('loaded'); - } - }} - onError={() => { - if (onFail) { - onFail(); - } else { - setLoadingStateInternal('failed'); - } - if (retryTime !== undefined) { - setTimeout(() => { - setLoadingStateInternal('loading'); - }, retryTime); - } - }} - />} -
-
-
- {title} -
-
- {description} -
-
- - ); -}; diff --git a/src/components/og/OGLoaderImage.tsx b/src/components/og/OGLoaderImage.tsx new file mode 100644 index 00000000..b5cbe272 --- /dev/null +++ b/src/components/og/OGLoaderImage.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { clsx } from 'clsx/lite'; +import Spinner from '@/components/Spinner'; +import { IMAGE_OG_DIMENSION } from '@/image-response'; +import { TbPhotoQuestion } from 'react-icons/tb'; + +export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; + +export default function OGLoaderImage({ + title, + path, + loadingState: loadingStateExternal, + onLoad, + onFail, + retryTime, + className, +}: { + title: string + path: string + loadingState?: OGLoadingState + onLoad?: () => void + onFail?: () => void + retryTime?: number + className?: string +}) { + + const [loadingStateInternal, setLoadingStateInternal] = + useState(loadingStateExternal ?? 'unloaded'); + + const loadingState = loadingStateExternal ?? loadingStateInternal; + + useEffect(() => { + if ( + !loadingStateExternal && + loadingStateInternal === 'unloaded' + ) { + setLoadingStateInternal('loading'); + } + }, [loadingStateExternal, loadingStateInternal]); + + const { width, height, aspectRatio } = IMAGE_OG_DIMENSION; + + return ( +
+ {loadingState === 'loading' && +
+ +
} + {loadingState === 'failed' && +
+ +
} + {(loadingState === 'loading' || loadingState === 'loaded') && + {title} { + if (onLoad) { + onLoad(); + } else { + setLoadingStateInternal('loaded'); + } + }} + onError={() => { + if (onFail) { + onFail(); + } else { + setLoadingStateInternal('failed'); + } + if (retryTime !== undefined) { + setTimeout(() => { + setLoadingStateInternal('loading'); + }, retryTime); + } + }} + />} +
+ ); +}; diff --git a/src/components/og/OGTile.tsx b/src/components/og/OGTile.tsx new file mode 100644 index 00000000..47c33554 --- /dev/null +++ b/src/components/og/OGTile.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { ComponentProps, useRef } from 'react'; +import { clsx } from 'clsx/lite'; +import Link from 'next/link'; +import useVisible from '@/utility/useVisible'; +import OGLoaderImage from './OGLoaderImage'; + +export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; + +export default function OGTile({ + path, + pathImage, + description, + riseOnHover, + onVisible, + ...props +}: { + description: string + pathImage: string + riseOnHover?: boolean + onVisible?: () => void +} & ComponentProps) { + const ref = useRef(null); + + useVisible({ ref, onVisible }); + + return ( + + +
+
+ {props.title} +
+
+ {description} +
+
+ + ); +}; diff --git a/src/components/og/OGTooltip.tsx b/src/components/og/OGTooltip.tsx new file mode 100644 index 00000000..9561254b --- /dev/null +++ b/src/components/og/OGTooltip.tsx @@ -0,0 +1,46 @@ +import { ComponentProps, ReactNode } from 'react'; +import TooltipPrimitive from '../primitives/TooltipPrimitive'; +import OGLoaderImage from './OGLoaderImage'; +import { IMAGE_OG_DIMENSION } from '@/image-response'; +import clsx from 'clsx/lite'; + +export default function OGTooltip({ + children, + caption, + ...props +}: { + children :ReactNode + caption?: ReactNode +} & ComponentProps) { + const { aspectRatio } = IMAGE_OG_DIMENSION; + return ( + + + {caption &&
+ {caption} +
} + } + > + {children} +
+ ); +} diff --git a/src/components/primitives/EntityLink.tsx b/src/components/primitives/EntityLink.tsx index 8f6cf2ad..9c7ebe26 100644 --- a/src/components/primitives/EntityLink.tsx +++ b/src/components/primitives/EntityLink.tsx @@ -7,12 +7,15 @@ import { clsx } from 'clsx/lite'; import LinkWithStatus from '../LinkWithStatus'; import Spinner from '../Spinner'; import ResponsiveText from './ResponsiveText'; +import OGTooltip from '../og/OGTooltip'; +import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config'; export interface EntityLinkExternalProps { ref?: RefObject type?: LabeledIconType badged?: boolean contrast?: ComponentProps['contrast'] + showTooltip?: boolean uppercase?: boolean prefetch?: boolean className?: string @@ -23,11 +26,15 @@ export default function EntityLink({ icon, label, labelSmall, + labelComplex, iconWide, type, badged, contrast = 'medium', - href = '', // Make link optional for debugging purposes + showTooltip = SHOW_CATEGORY_IMAGE_HOVERS, + path = '', // Make link optional for debugging purposes + tooltipImagePath, + tooltipCaption, prefetch, title, action, @@ -39,10 +46,13 @@ export default function EntityLink({ debug, }: { icon: ReactNode - label: ReactNode + label: string labelSmall?: ReactNode + labelComplex?: ReactNode iconWide?: boolean - href?: string + path?: string + tooltipImagePath?: string + tooltipCaption?: ReactNode prefetch?: boolean title?: string action?: ReactNode @@ -68,11 +78,60 @@ export default function EntityLink({ } }; + const showHoverEntity = + !isLoading && + hoverEntity !== undefined && + !showTooltip; + const renderLabel = - {label} + {labelComplex || label} ; + const renderLink = + + + {badged + ? + {renderLabel} + + : + {renderLabel} + } + + ; + return ( - - - {badged - ? - {renderLabel} - - : - {renderLabel} - } - - + {showTooltip && tooltipImagePath + ? + {renderLink} + + : renderLink} {action && {action} } - {!isLoading && hoverEntity !== undefined && + {showHoverEntity && {hoverEntity} } diff --git a/src/components/primitives/TooltipPrimitive.tsx b/src/components/primitives/TooltipPrimitive.tsx index cdf7022d..9762a503 100644 --- a/src/components/primitives/TooltipPrimitive.tsx +++ b/src/components/primitives/TooltipPrimitive.tsx @@ -20,6 +20,8 @@ export default function TooltipPrimitive({ color, keyCommand, keyCommandModifier, + disableHoverableContent, + debug, }: { content?: ReactNode children: ReactNode @@ -32,6 +34,8 @@ export default function TooltipPrimitive({ color?: ComponentProps['color'] keyCommand?: string keyCommandModifier?: ComponentProps['modifier'] + disableHoverableContent?: boolean + debug?: boolean }) { const refTrigger = useRef(null); const refContent = useRef(null); @@ -74,7 +78,10 @@ export default function TooltipPrimitive({ return ( - + {includeButton ?