OG image hovers (#268)
* Create og tooltip component * Refactor og image handling * Introduce category hover configuration * Add og hovers to all categories * Move category labels to client * Disable og tooltips in headers * Prevent og tooltips on accessory/loader hovers
This commit is contained in:
parent
5808444095
commit
e1af77d40c
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -28,6 +28,7 @@
|
||||
"Hasselblad",
|
||||
"headlessui",
|
||||
"hgetall",
|
||||
"Hoverable",
|
||||
"hset",
|
||||
"IIIA",
|
||||
"ILCE",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show category image hovers"
|
||||
status={showCategoryImageHover}
|
||||
optional
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
Set environment variable to {'"1"'} to show images when hovering
|
||||
over category links like cameras and lenses:
|
||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])}
|
||||
</div>
|
||||
<div>
|
||||
Static optimization strongly recommended
|
||||
for responsive hover interactions:
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
{renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])}
|
||||
</div>
|
||||
</div>
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show zoom controls"
|
||||
status={showZoomControls}
|
||||
|
||||
@ -240,7 +240,6 @@ export const IMAGE_QUALITY =
|
||||
export const BLUR_ENABLED =
|
||||
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
|
||||
|
||||
|
||||
// VISUAL
|
||||
|
||||
export const DEFAULT_THEME =
|
||||
@ -278,6 +277,8 @@ export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
||||
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||
export const SHOW_EXIF_DATA =
|
||||
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
|
||||
export const SHOW_CATEGORY_IMAGE_HOVERS =
|
||||
process.env.NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS === '1';
|
||||
export const SHOW_ZOOM_CONTROLS =
|
||||
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
||||
export const SHOW_TAKEN_AT_TIME =
|
||||
@ -403,6 +404,7 @@ export const APP_CONFIGURATION = {
|
||||
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
showExifInfo: SHOW_EXIF_DATA,
|
||||
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||
showSocial: SHOW_SOCIAL,
|
||||
|
||||
111
src/app/paths.ts
111
src/app/paths.ts
@ -67,6 +67,7 @@ export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`;
|
||||
|
||||
// Modifiers
|
||||
const EDIT = 'edit';
|
||||
const IMAGE = 'image';
|
||||
export const PARAM_UPLOAD_TITLE = 'title';
|
||||
|
||||
// Special characters
|
||||
@ -152,26 +153,51 @@ export const pathForPhoto = ({
|
||||
return `${prefix}/${getPhotoId(photo)}`;
|
||||
};
|
||||
|
||||
export const pathForTag = (tag: string) =>
|
||||
`${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(
|
||||
|
||||
@ -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 (
|
||||
<PhotoHeader
|
||||
camera={camera}
|
||||
entity={<PhotoCamera {...{ camera }} contrast="high" />}
|
||||
entity={<PhotoCamera
|
||||
{...{ camera }}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityDescription={
|
||||
descriptionForCameraPhotos(
|
||||
photos,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatCameraText(camera)}
|
||||
href={pathForCamera(camera)}
|
||||
path={pathForCamera(camera)}
|
||||
tooltipImagePath={pathForCameraImage(camera)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={showAppleIcon
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
|
||||
@ -366,7 +366,7 @@ export default function CommandKClient({
|
||||
heading: appText.category.focalLengthPlural,
|
||||
accessory: <IconFocalLength className="text-[14px]" />,
|
||||
items: focalLengths.map(({ focal, count }) => ({
|
||||
label: formatFocalLength(focal)!,
|
||||
label: formatFocalLength(focal),
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForFocalLength(focal),
|
||||
|
||||
@ -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<HTMLAnchorElement>(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 (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={path}
|
||||
className={clsx(
|
||||
'group',
|
||||
'block w-full rounded-md overflow-hidden',
|
||||
'border-medium shadow-xs',
|
||||
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{loadingState === 'loading' &&
|
||||
<div className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-10',
|
||||
'flex items-center justify-center',
|
||||
)}>
|
||||
<Spinner size={40} />
|
||||
</div>}
|
||||
{loadingState === 'failed' &&
|
||||
<div className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-11',
|
||||
'flex items-center justify-center',
|
||||
'text-red-400',
|
||||
)}>
|
||||
<BiError size={32} />
|
||||
</div>}
|
||||
{(loadingState === 'loading' || loadingState === 'loaded') &&
|
||||
<img
|
||||
alt={title}
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-0',
|
||||
'w-full',
|
||||
loadingState === 'loading' && 'opacity-0',
|
||||
'transition-opacity',
|
||||
)}
|
||||
src={pathImageAbsolute}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => {
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
} else {
|
||||
setLoadingStateInternal('loaded');
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
if (onFail) {
|
||||
onFail();
|
||||
} else {
|
||||
setLoadingStateInternal('failed');
|
||||
}
|
||||
if (retryTime !== undefined) {
|
||||
setTimeout(() => {
|
||||
setLoadingStateInternal('loading');
|
||||
}, retryTime);
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
'h-full flex flex-col gap-0.5 p-3',
|
||||
'font-sans leading-tight',
|
||||
'bg-gray-50 dark:bg-gray-900/50',
|
||||
'group-active:bg-gray-50 dark:group-active:bg-gray-900/50',
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900/70',
|
||||
'border-t border-gray-200 dark:border-gray-800',
|
||||
)}>
|
||||
<div className="text-gray-800 dark:text-white font-medium">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-medium">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
102
src/components/og/OGLoaderImage.tsx
Normal file
102
src/components/og/OGLoaderImage.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative',
|
||||
className,
|
||||
)}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{loadingState === 'loading' &&
|
||||
<div className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-10',
|
||||
'flex items-center justify-center',
|
||||
)}>
|
||||
<Spinner size={40} />
|
||||
</div>}
|
||||
{loadingState === 'failed' &&
|
||||
<div className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-11',
|
||||
'flex items-center justify-center',
|
||||
'text-dim',
|
||||
)}>
|
||||
<TbPhotoQuestion size={28} />
|
||||
</div>}
|
||||
{(loadingState === 'loading' || loadingState === 'loaded') &&
|
||||
<img
|
||||
alt={title}
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-0',
|
||||
'w-full',
|
||||
loadingState === 'loading' && 'opacity-0',
|
||||
'transition-opacity',
|
||||
)}
|
||||
src={path}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => {
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
} else {
|
||||
setLoadingStateInternal('loaded');
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
if (onFail) {
|
||||
onFail();
|
||||
} else {
|
||||
setLoadingStateInternal('failed');
|
||||
}
|
||||
if (retryTime !== undefined) {
|
||||
setTimeout(() => {
|
||||
setLoadingStateInternal('loading');
|
||||
}, retryTime);
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
src/components/og/OGTile.tsx
Normal file
57
src/components/og/OGTile.tsx
Normal file
@ -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<typeof OGLoaderImage>) {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useVisible({ ref, onVisible });
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={path}
|
||||
className={clsx(
|
||||
'group',
|
||||
'block w-full rounded-md overflow-hidden',
|
||||
'border-medium shadow-xs',
|
||||
riseOnHover && 'hover:-translate-y-1.5 transition-transform',
|
||||
)}
|
||||
>
|
||||
<OGLoaderImage {...{ ...props, path: pathImage }} />
|
||||
<div className={clsx(
|
||||
'h-full flex flex-col gap-0.5 p-3',
|
||||
'font-sans leading-tight',
|
||||
'bg-gray-50 dark:bg-gray-900/50',
|
||||
'group-active:bg-gray-50 dark:group-active:bg-gray-900/50',
|
||||
'group-hover:bg-gray-100 dark:group-hover:bg-gray-900/70',
|
||||
'border-t border-gray-200 dark:border-gray-800',
|
||||
)}>
|
||||
<div className="text-gray-800 dark:text-white font-medium">
|
||||
{props.title}
|
||||
</div>
|
||||
<div className="text-medium">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
46
src/components/og/OGTooltip.tsx
Normal file
46
src/components/og/OGTooltip.tsx
Normal file
@ -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<typeof OGLoaderImage>) {
|
||||
const { aspectRatio } = IMAGE_OG_DIMENSION;
|
||||
return (
|
||||
<TooltipPrimitive
|
||||
className="max-w-none p-1!"
|
||||
classNameTrigger="max-w-full"
|
||||
disableHoverableContent
|
||||
content={<div
|
||||
className="relative"
|
||||
style={{ width: 300, aspectRatio }}
|
||||
>
|
||||
<OGLoaderImage
|
||||
{...props}
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-[0.25rem]',
|
||||
'outline-medium bg-dim',
|
||||
)}
|
||||
/>
|
||||
{caption && <div className={clsx(
|
||||
'absolute left-3 bottom-3',
|
||||
'px-1.5 py-0.5 rounded-md',
|
||||
'text-white/90 bg-black/40 backdrop-blur-lg',
|
||||
'outline-medium shadow-sm',
|
||||
'uppercase text-xs',
|
||||
)}>
|
||||
{caption}
|
||||
</div>}
|
||||
</div>}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive>
|
||||
);
|
||||
}
|
||||
@ -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<HTMLSpanElement | null>
|
||||
type?: LabeledIconType
|
||||
badged?: boolean
|
||||
contrast?: ComponentProps<typeof Badge>['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,30 +78,25 @@ export default function EntityLink({
|
||||
}
|
||||
};
|
||||
|
||||
const showHoverEntity =
|
||||
!isLoading &&
|
||||
hoverEntity !== undefined &&
|
||||
!showTooltip;
|
||||
|
||||
const renderLabel =
|
||||
<ResponsiveText shortText={labelSmall}>
|
||||
{label}
|
||||
{labelComplex || label}
|
||||
</ResponsiveText>;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2',
|
||||
'max-w-full overflow-hidden select-none',
|
||||
// Underline link text when action is hovered
|
||||
'[&:has(.action:hover)_.text-content]:underline',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
const renderLink =
|
||||
<LinkWithStatus
|
||||
href={href}
|
||||
href={path}
|
||||
className={clsx(
|
||||
'peer',
|
||||
'inline-flex items-center gap-2 max-w-full truncate',
|
||||
classForContrast(),
|
||||
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
href && !badged && 'active:text-medium!',
|
||||
path && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||
path && !badged && 'active:text-medium!',
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
@ -99,7 +104,6 @@ export default function EntityLink({
|
||||
<LabeledIcon {...{
|
||||
icon,
|
||||
iconWide,
|
||||
href,
|
||||
prefetch,
|
||||
title,
|
||||
type,
|
||||
@ -126,12 +130,33 @@ export default function EntityLink({
|
||||
{renderLabel}
|
||||
</span>}
|
||||
</LabeledIcon>
|
||||
</LinkWithStatus>
|
||||
</LinkWithStatus>;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2',
|
||||
'max-w-full overflow-hidden select-none',
|
||||
// Underline link text when action is hovered
|
||||
'[&:has(.action:hover)_.text-content]:underline',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showTooltip && tooltipImagePath
|
||||
? <OGTooltip
|
||||
title={label}
|
||||
path={tooltipImagePath}
|
||||
caption={tooltipCaption}
|
||||
>
|
||||
{renderLink}
|
||||
</OGTooltip>
|
||||
: renderLink}
|
||||
{action &&
|
||||
<span className="action">
|
||||
{action}
|
||||
</span>}
|
||||
{!isLoading && hoverEntity !== undefined &&
|
||||
{showHoverEntity &&
|
||||
<span className="hidden peer-hover:inline text-dim">
|
||||
{hoverEntity}
|
||||
</span>}
|
||||
|
||||
@ -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<typeof MenuSurface>['color']
|
||||
keyCommand?: string
|
||||
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
|
||||
disableHoverableContent?: boolean
|
||||
debug?: boolean
|
||||
}) {
|
||||
const refTrigger = useRef<HTMLButtonElement>(null);
|
||||
const refContent = useRef<HTMLDivElement>(null);
|
||||
@ -74,7 +78,10 @@ export default function TooltipPrimitive({
|
||||
|
||||
return (
|
||||
<Tooltip.Provider {...{ delayDuration, skipDelayDuration }}>
|
||||
<Tooltip.Root open={includeButton ? isOpen : undefined}>
|
||||
<Tooltip.Root
|
||||
open={(includeButton ? isOpen : undefined) || debug}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
>
|
||||
<Tooltip.Trigger asChild>
|
||||
{includeButton
|
||||
? <button
|
||||
|
||||
@ -43,6 +43,7 @@ export default function FilmHeader({
|
||||
toggleRecipeOverlay={recipeProps
|
||||
? () => setRecipeModalProps?.(recipeProps)
|
||||
: undefined}
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityDescription={descriptionForFilmPhotos(
|
||||
photos,
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import {
|
||||
absolutePathForFilmImage,
|
||||
pathForFilm,
|
||||
pathForFilmImage,
|
||||
} from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
@ -37,7 +37,7 @@ export default function FilmOGTile({
|
||||
description:
|
||||
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
||||
path: pathForFilm(film),
|
||||
pathImageAbsolute: absolutePathForFilmImage(film),
|
||||
pathImage: pathForFilmImage(film),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import PhotoFilmIcon from './PhotoFilmIcon';
|
||||
import { pathForFilm } from '@/app/paths';
|
||||
import { pathForFilm, pathForFilmImage } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
@ -8,6 +10,8 @@ import { labelForFilm } from '.';
|
||||
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoFilm({
|
||||
film,
|
||||
@ -23,6 +27,7 @@ export default function PhotoFilm({
|
||||
countOnHover?: number
|
||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||
& EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
const { small, medium, large } = labelForFilm(film);
|
||||
|
||||
return (
|
||||
@ -30,7 +35,10 @@ export default function PhotoFilm({
|
||||
{...props}
|
||||
label={medium}
|
||||
labelSmall={small}
|
||||
href={pathForFilm(film)}
|
||||
path={pathForFilm(film)}
|
||||
tooltipImagePath={pathForFilmImage(film)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<PhotoFilmIcon
|
||||
film={film}
|
||||
className={clsx(
|
||||
|
||||
@ -24,7 +24,11 @@ export default async function FocalLengthHeader({
|
||||
return (
|
||||
<PhotoHeader
|
||||
focal={focal}
|
||||
entity={<PhotoFocalLength focal={focal} contrast="high" />}
|
||||
entity={<PhotoFocalLength
|
||||
focal={focal}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityDescription={descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
appText,
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import {
|
||||
absolutePathForFocalLengthImage,
|
||||
pathForFocalLength,
|
||||
pathForFocalLengthImage,
|
||||
} from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
@ -42,7 +42,7 @@ export default function FocalLengthOGTile({
|
||||
dateRange,
|
||||
),
|
||||
path: pathForFocalLength(focal),
|
||||
pathImageAbsolute: absolutePathForFocalLengthImage(focal),
|
||||
pathImage: pathForFocalLengthImage(focal),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
@ -2,7 +2,7 @@ import { absolutePathForFocalLength } from '@/app/paths';
|
||||
import { PhotoSetAttributes } from '../category';
|
||||
import ShareModal from '@/share/ShareModal';
|
||||
import FocalLengthOGTile from './FocalLengthOGTile';
|
||||
import { formatFocalLengthSafe, shareTextFocalLength } from '.';
|
||||
import { formatFocalLength, shareTextFocalLength } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function FocalLengthShareModal({
|
||||
@ -17,7 +17,7 @@ export default function FocalLengthShareModal({
|
||||
return (
|
||||
<ShareModal
|
||||
pathShare={absolutePathForFocalLength(focal, true)}
|
||||
navigatorTitle={formatFocalLengthSafe(focal)}
|
||||
navigatorTitle={formatFocalLength(focal)}
|
||||
socialText={shareTextFocalLength(focal, appText)}
|
||||
>
|
||||
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { pathForFocalLength } from '@/app/paths';
|
||||
'use client';
|
||||
|
||||
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import { formatFocalLength } from '.';
|
||||
import IconFocalLength from '@/components/icons/IconFocalLength';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoFocalLength({
|
||||
focal,
|
||||
@ -13,11 +17,16 @@ export default function PhotoFocalLength({
|
||||
focal: number
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatFocalLength(focal)}
|
||||
href={pathForFocalLength(focal)}
|
||||
path={pathForFocalLength(focal)}
|
||||
tooltipImagePath={pathForFocalLengthImage(focal)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
|
||||
@ -20,11 +20,7 @@ export const getFocalLengthFromString = (focalString?: string) => {
|
||||
return focal ? parseInt(focal, 10) : 0;
|
||||
};
|
||||
|
||||
export const formatFocalLength = (focal?: number) => focal
|
||||
? formatFocalLengthSafe(focal)
|
||||
: undefined;
|
||||
|
||||
export const formatFocalLengthSafe = (focal = 0) =>
|
||||
export const formatFocalLength = (focal = 0) =>
|
||||
`${focal}mm`;
|
||||
|
||||
export const titleForFocalLength = (
|
||||
@ -33,7 +29,7 @@ export const titleForFocalLength = (
|
||||
appText: AppTextState,
|
||||
explicitCount?: number,
|
||||
) => [
|
||||
appText.category.focalLengthTitle(formatFocalLengthSafe(focal)),
|
||||
appText.category.focalLengthTitle(formatFocalLength(focal)),
|
||||
photoQuantityText(explicitCount ?? photos.length, appText),
|
||||
].join(' ');
|
||||
|
||||
@ -41,7 +37,7 @@ export const shareTextFocalLength = (
|
||||
focal: number,
|
||||
appText: AppTextState,
|
||||
) =>
|
||||
appText.category.focalLengthShare(formatFocalLengthSafe(focal));
|
||||
appText.category.focalLengthShare(formatFocalLength(focal));
|
||||
|
||||
export const descriptionForFocalLengthPhotos = (
|
||||
photos: Photo[],
|
||||
|
||||
@ -26,7 +26,11 @@ export default async function LensHeader({
|
||||
return (
|
||||
<PhotoHeader
|
||||
lens={lens}
|
||||
entity={<PhotoLens {...{ lens }} contrast="high" />}
|
||||
entity={<PhotoLens
|
||||
{...{ lens }}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityDescription={
|
||||
descriptionForLensPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import { Lens } from '.';
|
||||
import { titleForLens, descriptionForLensPhotos } from './meta';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
@ -38,7 +38,7 @@ export default function LensOGTile({
|
||||
dateRange,
|
||||
),
|
||||
path: pathForLens(lens),
|
||||
pathImageAbsolute: absolutePathForLensImage(lens),
|
||||
pathImage: pathForLensImage(lens),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { pathForLens } from '@/app/paths';
|
||||
'use client';
|
||||
|
||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||
import { Lens, formatLensText } from '.';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import IconLens from '@/components/icons/IconLens';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoLens({
|
||||
lens,
|
||||
@ -15,11 +19,16 @@ export default function PhotoLens({
|
||||
countOnHover?: number
|
||||
shortText?: boolean
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
||||
href={pathForLens(lens)}
|
||||
path={pathForLens(lens)}
|
||||
tooltipImagePath={pathForLensImage(lens)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconLens
|
||||
size={14}
|
||||
className="translate-x-[-0.5px]"
|
||||
|
||||
@ -6,8 +6,8 @@ import {
|
||||
titleForPhoto,
|
||||
} from '@/photo';
|
||||
import { PhotoSetCategory } from '../category';
|
||||
import { absolutePathForPhotoImage, pathForPhoto } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
|
||||
export default function PhotoOGTile({
|
||||
photo,
|
||||
@ -32,7 +32,7 @@ export default function PhotoOGTile({
|
||||
title: titleForPhoto(photo),
|
||||
description: descriptionForPhoto(photo),
|
||||
path: pathForPhoto({ photo, ...categories }),
|
||||
pathImageAbsolute: absolutePathForPhotoImage(photo),
|
||||
pathImage: pathForPhotoImage(photo),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Photo } from '@/photo';
|
||||
import PhotoOGTile from './PhotoOGTile';
|
||||
import { OGLoadingState } from '@/components/OGTile';
|
||||
import { OGLoadingState } from '@/components/og/OGTile';
|
||||
|
||||
const DEFAULT_MAX_CONCURRENCY = 3;
|
||||
|
||||
|
||||
@ -123,9 +123,13 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
|
||||
...photoDb,
|
||||
tags: photoDb.tags ?? [],
|
||||
focalLengthFormatted:
|
||||
formatFocalLength(photoDb.focalLength),
|
||||
photoDb.focalLength !== undefined
|
||||
? formatFocalLength(photoDb.focalLength)
|
||||
: undefined,
|
||||
focalLengthIn35MmFormatFormatted:
|
||||
formatFocalLength(photoDb.focalLengthIn35MmFormat),
|
||||
photoDb.focalLengthIn35MmFormat !== undefined
|
||||
? formatFocalLength(photoDb.focalLengthIn35MmFormat)
|
||||
: undefined,
|
||||
fNumberFormatted:
|
||||
formatAperture(photoDb.fNumber),
|
||||
isoFormatted:
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { pathForRecipe } from '@/app/paths';
|
||||
'use client';
|
||||
|
||||
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
@ -7,6 +9,8 @@ import clsx from 'clsx/lite';
|
||||
import { ComponentProps } from 'react';
|
||||
import IconRecipe from '@/components/icons/IconRecipe';
|
||||
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoRecipe({
|
||||
ref,
|
||||
@ -20,13 +24,18 @@ export default function PhotoRecipe({
|
||||
countOnHover?: number
|
||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||
& EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
ref={ref}
|
||||
title="Recipe"
|
||||
label={formatRecipe(recipe)}
|
||||
href={pathForRecipe(recipe)}
|
||||
path={pathForRecipe(recipe)}
|
||||
tooltipImagePath={pathForRecipeImage(recipe)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconRecipe
|
||||
size={16}
|
||||
className={clsx(
|
||||
|
||||
@ -35,6 +35,7 @@ export default function RecipeHeader({
|
||||
entity={<PhotoRecipe
|
||||
recipe={recipe}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
||||
toggleRecipeOverlay={recipeProps
|
||||
? () => setRecipeModalProps?.(recipeProps)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import { descriptionForRecipePhotos, titleForRecipe } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
@ -37,7 +37,7 @@ export default function RecipeOGTile({
|
||||
dateRange,
|
||||
),
|
||||
path: pathForRecipe(recipe),
|
||||
pathImageAbsolute: absolutePathForRecipeImage(recipe),
|
||||
pathImage: pathForRecipeImage(recipe),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { TAG_FAVS } from '.';
|
||||
import { pathForTag } from '@/app/paths';
|
||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function FavsTag({
|
||||
type,
|
||||
@ -15,19 +17,24 @@ export default function FavsTag({
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
label={badged
|
||||
? <span className="inline-flex gap-1 items-center">
|
||||
label={TAG_FAVS}
|
||||
labelComplex={badged &&
|
||||
<span className="inline-flex gap-1 items-center">
|
||||
{TAG_FAVS}
|
||||
<IconFavs
|
||||
size={10}
|
||||
className="translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>
|
||||
</span>
|
||||
: TAG_FAVS}
|
||||
href={pathForTag(TAG_FAVS)}
|
||||
</span>}
|
||||
path={pathForTag(TAG_FAVS)}
|
||||
tooltipImagePath={pathForTagImage(TAG_FAVS)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={!badged &&
|
||||
<IconFavs
|
||||
size={13}
|
||||
|
||||
@ -17,16 +17,16 @@ export default function HiddenTag({
|
||||
} & EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
label={badged
|
||||
? <span className="inline-flex items-center gap-1">
|
||||
label={TAG_HIDDEN}
|
||||
labelComplex={badged &&
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{TAG_HIDDEN}
|
||||
<IconHidden
|
||||
size={13}
|
||||
className="translate-y-[-0.5px]"
|
||||
/>
|
||||
</span>
|
||||
: TAG_HIDDEN}
|
||||
href={pathForTag(TAG_HIDDEN)}
|
||||
</span>}
|
||||
path={pathForTag(TAG_HIDDEN)}
|
||||
icon={!badged && <IconHidden size={16} />}
|
||||
type={type}
|
||||
className={className}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { pathForTag } from '@/app/paths';
|
||||
'use client';
|
||||
|
||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||
import { formatTag } from '.';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
import IconTag from '@/components/icons/IconTag';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoTag({
|
||||
tag,
|
||||
@ -13,11 +17,16 @@ export default function PhotoTag({
|
||||
tag: string
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatTag(tag)}
|
||||
href={pathForTag(tag)}
|
||||
path={pathForTag(tag)}
|
||||
tooltipImagePath={pathForTagImage(tag)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
|
||||
@ -26,8 +26,15 @@ export default async function TagHeader({
|
||||
<PhotoHeader
|
||||
tag={tag}
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag contrast="high" />
|
||||
: <PhotoTag tag={tag} contrast="high" />}
|
||||
? <FavsTag
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>
|
||||
: <PhotoTag
|
||||
tag={tag}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
/>}
|
||||
entityVerb={appText.category.taggedPhotos}
|
||||
entityDescription={descriptionForTaggedPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { absolutePathForTagImage, pathForTag } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import { descriptionForTaggedPhotos, titleForTag } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
@ -39,7 +39,7 @@ export default function TagOGTile({
|
||||
dateRange,
|
||||
),
|
||||
path: pathForTag(tag),
|
||||
pathImageAbsolute: absolutePathForTagImage(tag),
|
||||
pathImage: pathForTagImage(tag),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user