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",
|
"Hasselblad",
|
||||||
"headlessui",
|
"headlessui",
|
||||||
"hgetall",
|
"hgetall",
|
||||||
|
"Hoverable",
|
||||||
"hset",
|
"hset",
|
||||||
"IIIA",
|
"IIIA",
|
||||||
"ILCE",
|
"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_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_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_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_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_TAKEN_AT_TIME = 1` hides taken at time from photo meta
|
||||||
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
|
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
|
|||||||
'image',
|
'image',
|
||||||
getUniqueFocalLengths,
|
getUniqueFocalLengths,
|
||||||
focalLengths => focalLengths
|
focalLengths => focalLengths
|
||||||
.map(({ focal }) => ({ focal: formatFocalLength(focal)! })),
|
.map(({ focal }) => ({ focal: formatFocalLength(focal) })),
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
collapseSidebarCategories,
|
collapseSidebarCategories,
|
||||||
showKeyboardShortcutTooltips,
|
showKeyboardShortcutTooltips,
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
|
showCategoryImageHover,
|
||||||
showZoomControls,
|
showZoomControls,
|
||||||
showTakenAtTimeHidden,
|
showTakenAtTimeHidden,
|
||||||
showSocial,
|
showSocial,
|
||||||
@ -647,6 +648,25 @@ export default function AdminAppConfigurationClient({
|
|||||||
Set environment variable to {'"1"'} to hide EXIF data:
|
Set environment variable to {'"1"'} to hide EXIF data:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Show zoom controls"
|
title="Show zoom controls"
|
||||||
status={showZoomControls}
|
status={showZoomControls}
|
||||||
|
|||||||
@ -240,7 +240,6 @@ export const IMAGE_QUALITY =
|
|||||||
export const BLUR_ENABLED =
|
export const BLUR_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
|
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
|
||||||
|
|
||||||
|
|
||||||
// VISUAL
|
// VISUAL
|
||||||
|
|
||||||
export const DEFAULT_THEME =
|
export const DEFAULT_THEME =
|
||||||
@ -278,6 +277,8 @@ export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
|||||||
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||||
export const SHOW_EXIF_DATA =
|
export const SHOW_EXIF_DATA =
|
||||||
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
|
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 =
|
export const SHOW_ZOOM_CONTROLS =
|
||||||
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
||||||
export const SHOW_TAKEN_AT_TIME =
|
export const SHOW_TAKEN_AT_TIME =
|
||||||
@ -403,6 +404,7 @@ export const APP_CONFIGURATION = {
|
|||||||
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
||||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
|
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
||||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||||
showSocial: SHOW_SOCIAL,
|
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
|
// Modifiers
|
||||||
const EDIT = 'edit';
|
const EDIT = 'edit';
|
||||||
|
const IMAGE = 'image';
|
||||||
export const PARAM_UPLOAD_TITLE = 'title';
|
export const PARAM_UPLOAD_TITLE = 'title';
|
||||||
|
|
||||||
// Special characters
|
// Special characters
|
||||||
@ -152,26 +153,51 @@ export const pathForPhoto = ({
|
|||||||
return `${prefix}/${getPhotoId(photo)}`;
|
return `${prefix}/${getPhotoId(photo)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pathForTag = (tag: string) =>
|
|
||||||
`${PREFIX_TAG}/${tag}`;
|
|
||||||
|
|
||||||
export const pathForCamera = ({ make, model }: Camera) =>
|
export const pathForCamera = ({ make, model }: Camera) =>
|
||||||
`${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`;
|
`${PREFIX_CAMERA}/${parameterize(make)}/${parameterize(model)}`;
|
||||||
|
|
||||||
export const pathForFilm = (film: string) =>
|
|
||||||
`${PREFIX_FILM}/${film}`;
|
|
||||||
|
|
||||||
export const pathForLens = ({ make, model }: Lens) =>
|
export const pathForLens = ({ make, model }: Lens) =>
|
||||||
make
|
make
|
||||||
? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}`
|
? `${PREFIX_LENS}/${parameterize(make)}/${parameterize(model)}`
|
||||||
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
|
: `${PREFIX_LENS}/${MISSING_FIELD}/${parameterize(model)}`;
|
||||||
|
|
||||||
export const pathForFocalLength = (focal: number) =>
|
export const pathForTag = (tag: string) =>
|
||||||
`${PREFIX_FOCAL_LENGTH}/${focal}mm`;
|
`${PREFIX_TAG}/${tag}`;
|
||||||
|
|
||||||
export const pathForRecipe = (recipe: string) =>
|
export const pathForRecipe = (recipe: string) =>
|
||||||
`${PREFIX_RECIPE}/${recipe}`;
|
`${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
|
// Absolute paths
|
||||||
export const ABSOLUTE_PATH_FOR_FEED_JSON =
|
export const ABSOLUTE_PATH_FOR_FEED_JSON =
|
||||||
`${getBaseUrl()}${PATH_FEED_JSON}`;
|
`${getBaseUrl()}${PATH_FEED_JSON}`;
|
||||||
@ -188,58 +214,49 @@ export const absolutePathForPhoto = (
|
|||||||
) =>
|
) =>
|
||||||
`${getBaseUrl(share)}${pathForPhoto(params)}`;
|
`${getBaseUrl(share)}${pathForPhoto(params)}`;
|
||||||
|
|
||||||
export const absolutePathForTag = (tag: string, share?: boolean) =>
|
|
||||||
`${getBaseUrl(share)}${pathForTag(tag)}`;
|
|
||||||
|
|
||||||
export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
|
export const absolutePathForCamera= (camera: Camera, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForCamera(camera)}`;
|
`${getBaseUrl(share)}${pathForCamera(camera)}`;
|
||||||
|
|
||||||
export const absolutePathForLens= (lens: Lens, share?: boolean) =>
|
export const absolutePathForLens= (lens: Lens, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForLens(lens)}`;
|
`${getBaseUrl(share)}${pathForLens(lens)}`;
|
||||||
|
|
||||||
export const absolutePathForFilm = (film: string, share?: boolean) =>
|
export const absolutePathForTag = (tag: string, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForFilm(film)}`;
|
`${getBaseUrl(share)}${pathForTag(tag)}`;
|
||||||
|
|
||||||
export const absolutePathForRecipe = (recipe: string, share?: boolean) =>
|
export const absolutePathForRecipe = (recipe: string, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForRecipe(recipe)}`;
|
`${getBaseUrl(share)}${pathForRecipe(recipe)}`;
|
||||||
|
|
||||||
|
export const absolutePathForFilm = (film: string, share?: boolean) =>
|
||||||
|
`${getBaseUrl(share)}${pathForFilm(film)}`;
|
||||||
|
|
||||||
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
|
export const absolutePathForFocalLength = (focal: number, share?: boolean) =>
|
||||||
`${getBaseUrl(share)}${pathForFocalLength(focal)}`;
|
`${getBaseUrl(share)}${pathForFocalLength(focal)}`;
|
||||||
|
|
||||||
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
export const absolutePathForPhotoImage = (photo: PhotoOrPhotoId) =>
|
||||||
`${absolutePathForPhoto({ photo })}/image`;
|
`${getBaseUrl()}${pathForPhotoImage(photo)}`;
|
||||||
|
|
||||||
export const absolutePathForTagImage = (tag: string) =>
|
|
||||||
`${absolutePathForTag(tag)}/image`;
|
|
||||||
|
|
||||||
export const absolutePathForCameraImage= (camera: Camera) =>
|
export const absolutePathForCameraImage= (camera: Camera) =>
|
||||||
`${absolutePathForCamera(camera)}/image`;
|
`${getBaseUrl()}${pathForCameraImage(camera)}`;
|
||||||
|
|
||||||
export const absolutePathForLensImage= (lens: Lens) =>
|
export const absolutePathForLensImage= (lens: Lens) =>
|
||||||
`${absolutePathForLens(lens)}/image`;
|
`${getBaseUrl()}${pathForLensImage(lens)}`;
|
||||||
|
|
||||||
export const absolutePathForFilmImage = (film: string) =>
|
export const absolutePathForTagImage = (tag: string) =>
|
||||||
`${absolutePathForFilm(film)}/image`;
|
`${getBaseUrl()}${pathForTagImage(tag)}`;
|
||||||
|
|
||||||
export const absolutePathForRecipeImage = (recipe: string) =>
|
export const absolutePathForRecipeImage = (recipe: string) =>
|
||||||
`${absolutePathForRecipe(recipe)}/image`;
|
`${getBaseUrl()}${pathForRecipeImage(recipe)}`;
|
||||||
|
|
||||||
export const absolutePathForFocalLengthImage =
|
export const absolutePathForFilmImage = (film: string) =>
|
||||||
(focal: number) =>
|
`${getBaseUrl()}${pathForFilmImage(film)}`;
|
||||||
`${absolutePathForFocalLength(focal)}/image`;
|
|
||||||
|
export const absolutePathForFocalLengthImage = (focal: number) =>
|
||||||
|
`${getBaseUrl()}${pathForFocalLengthImage(focal)}`;
|
||||||
|
|
||||||
// p/[photoId]
|
// p/[photoId]
|
||||||
export const isPathPhoto = (pathname = '') =>
|
export const isPathPhoto = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_PHOTO}/[^/]+/?$`).test(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]
|
// shot-on/[make]/[model]
|
||||||
export const isPathCamera = (pathname = '') =>
|
export const isPathCamera = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
|
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/?$`).test(pathname);
|
||||||
@ -248,6 +265,22 @@ export const isPathCamera = (pathname = '') =>
|
|||||||
export const isPathCameraPhoto = (pathname = '') =>
|
export const isPathCameraPhoto = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/[^/]+/?$`).test(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]
|
// film/[film]
|
||||||
export const isPathFilm = (pathname = '') =>
|
export const isPathFilm = (pathname = '') =>
|
||||||
new RegExp(`^${PREFIX_FILM}/[^/]+/?$`).test(pathname);
|
new RegExp(`^${PREFIX_FILM}/[^/]+/?$`).test(pathname);
|
||||||
@ -313,20 +346,20 @@ export const getPathComponents = (pathname = ''): {
|
|||||||
} & PhotoSetCategory => {
|
} & PhotoSetCategory => {
|
||||||
const photoIdFromPhoto = pathname.match(
|
const photoIdFromPhoto = pathname.match(
|
||||||
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
|
new RegExp(`^${PREFIX_PHOTO}/([^/]+)`))?.[1];
|
||||||
const photoIdFromTag = pathname.match(
|
|
||||||
new RegExp(`^${PREFIX_TAG}/[^/]+/([^/]+)`))?.[1];
|
|
||||||
const photoIdFromCamera = pathname.match(
|
const photoIdFromCamera = pathname.match(
|
||||||
new RegExp(`^${PREFIX_CAMERA}/[^/]+/[^/]+/([^/]+)`))?.[1];
|
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(
|
const photoIdFromFilm = pathname.match(
|
||||||
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
|
new RegExp(`^${PREFIX_FILM}/[^/]+/([^/]+)`))?.[1];
|
||||||
const photoIdFromFocalLength = pathname.match(
|
const photoIdFromFocalLength = pathname.match(
|
||||||
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
|
new RegExp(`^${PREFIX_FOCAL_LENGTH}/[0-9]+mm/([^/]+)`))?.[1];
|
||||||
const tag = pathname.match(
|
const tag = pathname.match(
|
||||||
new RegExp(`^${PREFIX_TAG}/([^/]+)`))?.[1];
|
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(
|
const film = pathname.match(
|
||||||
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
|
new RegExp(`^${PREFIX_FILM}/([^/]+)`))?.[1] as string;
|
||||||
const focalString = pathname.match(
|
const focalString = pathname.match(
|
||||||
|
|||||||
@ -21,12 +21,17 @@ export default async function CameraHeader({
|
|||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
}) {
|
||||||
const camera = cameraFromPhoto(photos[0], cameraProp);
|
|
||||||
const appText = await getAppText();
|
const appText = await getAppText();
|
||||||
|
const camera = cameraFromPhoto(photos[0], cameraProp);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
camera={camera}
|
camera={camera}
|
||||||
entity={<PhotoCamera {...{ camera }} contrast="high" />}
|
entity={<PhotoCamera
|
||||||
|
{...{ camera }}
|
||||||
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>}
|
||||||
entityDescription={
|
entityDescription={
|
||||||
descriptionForCameraPhotos(
|
descriptionForCameraPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { absolutePathForCameraImage, pathForCamera } from '@/app/paths';
|
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { Camera } from '.';
|
import { Camera } from '.';
|
||||||
import { descriptionForCameraPhotos, titleForCamera } from './meta';
|
import { descriptionForCameraPhotos, titleForCamera } from './meta';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
@ -40,7 +40,7 @@ export default function CameraOGTile({
|
|||||||
dateRange,
|
dateRange,
|
||||||
),
|
),
|
||||||
path: pathForCamera(camera),
|
path: pathForCamera(camera),
|
||||||
pathImageAbsolute: absolutePathForCameraImage(camera),
|
pathImage: pathForCameraImage(camera),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { AiFillApple } from 'react-icons/ai';
|
import { AiFillApple } from 'react-icons/ai';
|
||||||
import { pathForCamera } from '@/app/paths';
|
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
||||||
import { Camera, formatCameraText } from '.';
|
import { Camera, formatCameraText } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
import IconCamera from '@/components/icons/IconCamera';
|
import IconCamera from '@/components/icons/IconCamera';
|
||||||
import { isCameraApple } from '@/platforms/apple';
|
import { isCameraApple } from '@/platforms/apple';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoCamera({
|
export default function PhotoCamera({
|
||||||
camera,
|
camera,
|
||||||
@ -17,6 +21,7 @@ export default function PhotoCamera({
|
|||||||
hideAppleIcon?: boolean
|
hideAppleIcon?: boolean
|
||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
const isApple = isCameraApple(camera);
|
const isApple = isCameraApple(camera);
|
||||||
const showAppleIcon = !hideAppleIcon && isApple;
|
const showAppleIcon = !hideAppleIcon && isApple;
|
||||||
|
|
||||||
@ -24,7 +29,10 @@ export default function PhotoCamera({
|
|||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatCameraText(camera)}
|
label={formatCameraText(camera)}
|
||||||
href={pathForCamera(camera)}
|
path={pathForCamera(camera)}
|
||||||
|
tooltipImagePath={pathForCameraImage(camera)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={showAppleIcon
|
icon={showAppleIcon
|
||||||
? <AiFillApple
|
? <AiFillApple
|
||||||
title="Apple"
|
title="Apple"
|
||||||
|
|||||||
@ -366,7 +366,7 @@ export default function CommandKClient({
|
|||||||
heading: appText.category.focalLengthPlural,
|
heading: appText.category.focalLengthPlural,
|
||||||
accessory: <IconFocalLength className="text-[14px]" />,
|
accessory: <IconFocalLength className="text-[14px]" />,
|
||||||
items: focalLengths.map(({ focal, count }) => ({
|
items: focalLengths.map(({ focal, count }) => ({
|
||||||
label: formatFocalLength(focal)!,
|
label: formatFocalLength(focal),
|
||||||
annotation: formatCount(count),
|
annotation: formatCount(count),
|
||||||
annotationAria: formatCountDescriptive(count),
|
annotationAria: formatCountDescriptive(count),
|
||||||
path: pathForFocalLength(focal),
|
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 LinkWithStatus from '../LinkWithStatus';
|
||||||
import Spinner from '../Spinner';
|
import Spinner from '../Spinner';
|
||||||
import ResponsiveText from './ResponsiveText';
|
import ResponsiveText from './ResponsiveText';
|
||||||
|
import OGTooltip from '../og/OGTooltip';
|
||||||
|
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
|
||||||
|
|
||||||
export interface EntityLinkExternalProps {
|
export interface EntityLinkExternalProps {
|
||||||
ref?: RefObject<HTMLSpanElement | null>
|
ref?: RefObject<HTMLSpanElement | null>
|
||||||
type?: LabeledIconType
|
type?: LabeledIconType
|
||||||
badged?: boolean
|
badged?: boolean
|
||||||
contrast?: ComponentProps<typeof Badge>['contrast']
|
contrast?: ComponentProps<typeof Badge>['contrast']
|
||||||
|
showTooltip?: boolean
|
||||||
uppercase?: boolean
|
uppercase?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
@ -23,11 +26,15 @@ export default function EntityLink({
|
|||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
labelSmall,
|
labelSmall,
|
||||||
|
labelComplex,
|
||||||
iconWide,
|
iconWide,
|
||||||
type,
|
type,
|
||||||
badged,
|
badged,
|
||||||
contrast = 'medium',
|
contrast = 'medium',
|
||||||
href = '', // Make link optional for debugging purposes
|
showTooltip = SHOW_CATEGORY_IMAGE_HOVERS,
|
||||||
|
path = '', // Make link optional for debugging purposes
|
||||||
|
tooltipImagePath,
|
||||||
|
tooltipCaption,
|
||||||
prefetch,
|
prefetch,
|
||||||
title,
|
title,
|
||||||
action,
|
action,
|
||||||
@ -39,10 +46,13 @@ export default function EntityLink({
|
|||||||
debug,
|
debug,
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
label: ReactNode
|
label: string
|
||||||
labelSmall?: ReactNode
|
labelSmall?: ReactNode
|
||||||
|
labelComplex?: ReactNode
|
||||||
iconWide?: boolean
|
iconWide?: boolean
|
||||||
href?: string
|
path?: string
|
||||||
|
tooltipImagePath?: string
|
||||||
|
tooltipCaption?: ReactNode
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
@ -68,30 +78,25 @@ export default function EntityLink({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showHoverEntity =
|
||||||
|
!isLoading &&
|
||||||
|
hoverEntity !== undefined &&
|
||||||
|
!showTooltip;
|
||||||
|
|
||||||
const renderLabel =
|
const renderLabel =
|
||||||
<ResponsiveText shortText={labelSmall}>
|
<ResponsiveText shortText={labelSmall}>
|
||||||
{label}
|
{labelComplex || label}
|
||||||
</ResponsiveText>;
|
</ResponsiveText>;
|
||||||
|
|
||||||
return (
|
const renderLink =
|
||||||
<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,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LinkWithStatus
|
<LinkWithStatus
|
||||||
href={href}
|
href={path}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'peer',
|
'peer',
|
||||||
'inline-flex items-center gap-2 max-w-full truncate',
|
'inline-flex items-center gap-2 max-w-full truncate',
|
||||||
classForContrast(),
|
classForContrast(),
|
||||||
href && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
path && !badged && 'hover:text-gray-900 dark:hover:text-gray-100',
|
||||||
href && !badged && 'active:text-medium!',
|
path && !badged && 'active:text-medium!',
|
||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
setIsLoading={setIsLoading}
|
setIsLoading={setIsLoading}
|
||||||
@ -99,7 +104,6 @@ export default function EntityLink({
|
|||||||
<LabeledIcon {...{
|
<LabeledIcon {...{
|
||||||
icon,
|
icon,
|
||||||
iconWide,
|
iconWide,
|
||||||
href,
|
|
||||||
prefetch,
|
prefetch,
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@ -126,12 +130,33 @@ export default function EntityLink({
|
|||||||
{renderLabel}
|
{renderLabel}
|
||||||
</span>}
|
</span>}
|
||||||
</LabeledIcon>
|
</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 &&
|
{action &&
|
||||||
<span className="action">
|
<span className="action">
|
||||||
{action}
|
{action}
|
||||||
</span>}
|
</span>}
|
||||||
{!isLoading && hoverEntity !== undefined &&
|
{showHoverEntity &&
|
||||||
<span className="hidden peer-hover:inline text-dim">
|
<span className="hidden peer-hover:inline text-dim">
|
||||||
{hoverEntity}
|
{hoverEntity}
|
||||||
</span>}
|
</span>}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export default function TooltipPrimitive({
|
|||||||
color,
|
color,
|
||||||
keyCommand,
|
keyCommand,
|
||||||
keyCommandModifier,
|
keyCommandModifier,
|
||||||
|
disableHoverableContent,
|
||||||
|
debug,
|
||||||
}: {
|
}: {
|
||||||
content?: ReactNode
|
content?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@ -32,6 +34,8 @@ export default function TooltipPrimitive({
|
|||||||
color?: ComponentProps<typeof MenuSurface>['color']
|
color?: ComponentProps<typeof MenuSurface>['color']
|
||||||
keyCommand?: string
|
keyCommand?: string
|
||||||
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
|
keyCommandModifier?: ComponentProps<typeof KeyCommand>['modifier']
|
||||||
|
disableHoverableContent?: boolean
|
||||||
|
debug?: boolean
|
||||||
}) {
|
}) {
|
||||||
const refTrigger = useRef<HTMLButtonElement>(null);
|
const refTrigger = useRef<HTMLButtonElement>(null);
|
||||||
const refContent = useRef<HTMLDivElement>(null);
|
const refContent = useRef<HTMLDivElement>(null);
|
||||||
@ -74,7 +78,10 @@ export default function TooltipPrimitive({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider {...{ delayDuration, skipDelayDuration }}>
|
<Tooltip.Provider {...{ delayDuration, skipDelayDuration }}>
|
||||||
<Tooltip.Root open={includeButton ? isOpen : undefined}>
|
<Tooltip.Root
|
||||||
|
open={(includeButton ? isOpen : undefined) || debug}
|
||||||
|
disableHoverableContent={disableHoverableContent}
|
||||||
|
>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
{includeButton
|
{includeButton
|
||||||
? <button
|
? <button
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export default function FilmHeader({
|
|||||||
toggleRecipeOverlay={recipeProps
|
toggleRecipeOverlay={recipeProps
|
||||||
? () => setRecipeModalProps?.(recipeProps)
|
? () => setRecipeModalProps?.(recipeProps)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
showTooltip={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={descriptionForFilmPhotos(
|
entityDescription={descriptionForFilmPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import {
|
import {
|
||||||
absolutePathForFilmImage,
|
|
||||||
pathForFilm,
|
pathForFilm,
|
||||||
|
pathForFilmImage,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ export default function FilmOGTile({
|
|||||||
description:
|
description:
|
||||||
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
||||||
path: pathForFilm(film),
|
path: pathForFilm(film),
|
||||||
pathImageAbsolute: absolutePathForFilmImage(film),
|
pathImage: pathForFilmImage(film),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import PhotoFilmIcon from './PhotoFilmIcon';
|
import PhotoFilmIcon from './PhotoFilmIcon';
|
||||||
import { pathForFilm } from '@/app/paths';
|
import { pathForFilm, pathForFilmImage } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
@ -8,6 +10,8 @@ import { labelForFilm } from '.';
|
|||||||
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoFilm({
|
export default function PhotoFilm({
|
||||||
film,
|
film,
|
||||||
@ -23,6 +27,7 @@ export default function PhotoFilm({
|
|||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||||
& EntityLinkExternalProps) {
|
& EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
const { small, medium, large } = labelForFilm(film);
|
const { small, medium, large } = labelForFilm(film);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -30,7 +35,10 @@ export default function PhotoFilm({
|
|||||||
{...props}
|
{...props}
|
||||||
label={medium}
|
label={medium}
|
||||||
labelSmall={small}
|
labelSmall={small}
|
||||||
href={pathForFilm(film)}
|
path={pathForFilm(film)}
|
||||||
|
tooltipImagePath={pathForFilmImage(film)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={<PhotoFilmIcon
|
icon={<PhotoFilmIcon
|
||||||
film={film}
|
film={film}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@ -24,7 +24,11 @@ export default async function FocalLengthHeader({
|
|||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
focal={focal}
|
focal={focal}
|
||||||
entity={<PhotoFocalLength focal={focal} contrast="high" />}
|
entity={<PhotoFocalLength
|
||||||
|
focal={focal}
|
||||||
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>}
|
||||||
entityDescription={descriptionForFocalLengthPhotos(
|
entityDescription={descriptionForFocalLengthPhotos(
|
||||||
photos,
|
photos,
|
||||||
appText,
|
appText,
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import {
|
import {
|
||||||
absolutePathForFocalLengthImage,
|
|
||||||
pathForFocalLength,
|
pathForFocalLength,
|
||||||
|
pathForFocalLengthImage,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ export default function FocalLengthOGTile({
|
|||||||
dateRange,
|
dateRange,
|
||||||
),
|
),
|
||||||
path: pathForFocalLength(focal),
|
path: pathForFocalLength(focal),
|
||||||
pathImageAbsolute: absolutePathForFocalLengthImage(focal),
|
pathImage: pathForFocalLengthImage(focal),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { absolutePathForFocalLength } from '@/app/paths';
|
|||||||
import { PhotoSetAttributes } from '../category';
|
import { PhotoSetAttributes } from '../category';
|
||||||
import ShareModal from '@/share/ShareModal';
|
import ShareModal from '@/share/ShareModal';
|
||||||
import FocalLengthOGTile from './FocalLengthOGTile';
|
import FocalLengthOGTile from './FocalLengthOGTile';
|
||||||
import { formatFocalLengthSafe, shareTextFocalLength } from '.';
|
import { formatFocalLength, shareTextFocalLength } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function FocalLengthShareModal({
|
export default function FocalLengthShareModal({
|
||||||
@ -17,7 +17,7 @@ export default function FocalLengthShareModal({
|
|||||||
return (
|
return (
|
||||||
<ShareModal
|
<ShareModal
|
||||||
pathShare={absolutePathForFocalLength(focal, true)}
|
pathShare={absolutePathForFocalLength(focal, true)}
|
||||||
navigatorTitle={formatFocalLengthSafe(focal)}
|
navigatorTitle={formatFocalLength(focal)}
|
||||||
socialText={shareTextFocalLength(focal, appText)}
|
socialText={shareTextFocalLength(focal, appText)}
|
||||||
>
|
>
|
||||||
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { pathForFocalLength } from '@/app/paths';
|
'use client';
|
||||||
|
|
||||||
|
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
import { formatFocalLength } from '.';
|
import { formatFocalLength } from '.';
|
||||||
import IconFocalLength from '@/components/icons/IconFocalLength';
|
import IconFocalLength from '@/components/icons/IconFocalLength';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoFocalLength({
|
export default function PhotoFocalLength({
|
||||||
focal,
|
focal,
|
||||||
@ -13,11 +17,16 @@ export default function PhotoFocalLength({
|
|||||||
focal: number
|
focal: number
|
||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatFocalLength(focal)}
|
label={formatFocalLength(focal)}
|
||||||
href={pathForFocalLength(focal)}
|
path={pathForFocalLength(focal)}
|
||||||
|
tooltipImagePath={pathForFocalLengthImage(focal)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
||||||
hoverEntity={countOnHover}
|
hoverEntity={countOnHover}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -20,11 +20,7 @@ export const getFocalLengthFromString = (focalString?: string) => {
|
|||||||
return focal ? parseInt(focal, 10) : 0;
|
return focal ? parseInt(focal, 10) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatFocalLength = (focal?: number) => focal
|
export const formatFocalLength = (focal = 0) =>
|
||||||
? formatFocalLengthSafe(focal)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
export const formatFocalLengthSafe = (focal = 0) =>
|
|
||||||
`${focal}mm`;
|
`${focal}mm`;
|
||||||
|
|
||||||
export const titleForFocalLength = (
|
export const titleForFocalLength = (
|
||||||
@ -33,7 +29,7 @@ export const titleForFocalLength = (
|
|||||||
appText: AppTextState,
|
appText: AppTextState,
|
||||||
explicitCount?: number,
|
explicitCount?: number,
|
||||||
) => [
|
) => [
|
||||||
appText.category.focalLengthTitle(formatFocalLengthSafe(focal)),
|
appText.category.focalLengthTitle(formatFocalLength(focal)),
|
||||||
photoQuantityText(explicitCount ?? photos.length, appText),
|
photoQuantityText(explicitCount ?? photos.length, appText),
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
@ -41,7 +37,7 @@ export const shareTextFocalLength = (
|
|||||||
focal: number,
|
focal: number,
|
||||||
appText: AppTextState,
|
appText: AppTextState,
|
||||||
) =>
|
) =>
|
||||||
appText.category.focalLengthShare(formatFocalLengthSafe(focal));
|
appText.category.focalLengthShare(formatFocalLength(focal));
|
||||||
|
|
||||||
export const descriptionForFocalLengthPhotos = (
|
export const descriptionForFocalLengthPhotos = (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
|
|||||||
@ -26,7 +26,11 @@ export default async function LensHeader({
|
|||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
lens={lens}
|
lens={lens}
|
||||||
entity={<PhotoLens {...{ lens }} contrast="high" />}
|
entity={<PhotoLens
|
||||||
|
{...{ lens }}
|
||||||
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>}
|
||||||
entityDescription={
|
entityDescription={
|
||||||
descriptionForLensPhotos(
|
descriptionForLensPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
|
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { Lens } from '.';
|
import { Lens } from '.';
|
||||||
import { titleForLens, descriptionForLensPhotos } from './meta';
|
import { titleForLens, descriptionForLensPhotos } from './meta';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
@ -38,7 +38,7 @@ export default function LensOGTile({
|
|||||||
dateRange,
|
dateRange,
|
||||||
),
|
),
|
||||||
path: pathForLens(lens),
|
path: pathForLens(lens),
|
||||||
pathImageAbsolute: absolutePathForLensImage(lens),
|
pathImage: pathForLensImage(lens),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { pathForLens } from '@/app/paths';
|
'use client';
|
||||||
|
|
||||||
|
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||||
import { Lens, formatLensText } from '.';
|
import { Lens, formatLensText } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
import IconLens from '@/components/icons/IconLens';
|
import IconLens from '@/components/icons/IconLens';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoLens({
|
export default function PhotoLens({
|
||||||
lens,
|
lens,
|
||||||
@ -15,11 +19,16 @@ export default function PhotoLens({
|
|||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
shortText?: boolean
|
shortText?: boolean
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
||||||
href={pathForLens(lens)}
|
path={pathForLens(lens)}
|
||||||
|
tooltipImagePath={pathForLensImage(lens)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={<IconLens
|
icon={<IconLens
|
||||||
size={14}
|
size={14}
|
||||||
className="translate-x-[-0.5px]"
|
className="translate-x-[-0.5px]"
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import {
|
|||||||
titleForPhoto,
|
titleForPhoto,
|
||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import { absolutePathForPhotoImage, pathForPhoto } from '@/app/paths';
|
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
|
|
||||||
export default function PhotoOGTile({
|
export default function PhotoOGTile({
|
||||||
photo,
|
photo,
|
||||||
@ -32,7 +32,7 @@ export default function PhotoOGTile({
|
|||||||
title: titleForPhoto(photo),
|
title: titleForPhoto(photo),
|
||||||
description: descriptionForPhoto(photo),
|
description: descriptionForPhoto(photo),
|
||||||
path: pathForPhoto({ photo, ...categories }),
|
path: pathForPhoto({ photo, ...categories }),
|
||||||
pathImageAbsolute: absolutePathForPhotoImage(photo),
|
pathImage: pathForPhotoImage(photo),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Photo } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import PhotoOGTile from './PhotoOGTile';
|
import PhotoOGTile from './PhotoOGTile';
|
||||||
import { OGLoadingState } from '@/components/OGTile';
|
import { OGLoadingState } from '@/components/og/OGTile';
|
||||||
|
|
||||||
const DEFAULT_MAX_CONCURRENCY = 3;
|
const DEFAULT_MAX_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
|||||||
@ -123,9 +123,13 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
|
|||||||
...photoDb,
|
...photoDb,
|
||||||
tags: photoDb.tags ?? [],
|
tags: photoDb.tags ?? [],
|
||||||
focalLengthFormatted:
|
focalLengthFormatted:
|
||||||
formatFocalLength(photoDb.focalLength),
|
photoDb.focalLength !== undefined
|
||||||
|
? formatFocalLength(photoDb.focalLength)
|
||||||
|
: undefined,
|
||||||
focalLengthIn35MmFormatFormatted:
|
focalLengthIn35MmFormatFormatted:
|
||||||
formatFocalLength(photoDb.focalLengthIn35MmFormat),
|
photoDb.focalLengthIn35MmFormat !== undefined
|
||||||
|
? formatFocalLength(photoDb.focalLengthIn35MmFormat)
|
||||||
|
: undefined,
|
||||||
fNumberFormatted:
|
fNumberFormatted:
|
||||||
formatAperture(photoDb.fNumber),
|
formatAperture(photoDb.fNumber),
|
||||||
isoFormatted:
|
isoFormatted:
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { pathForRecipe } from '@/app/paths';
|
'use client';
|
||||||
|
|
||||||
|
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
@ -7,6 +9,8 @@ import clsx from 'clsx/lite';
|
|||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import IconRecipe from '@/components/icons/IconRecipe';
|
import IconRecipe from '@/components/icons/IconRecipe';
|
||||||
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoRecipe({
|
export default function PhotoRecipe({
|
||||||
ref,
|
ref,
|
||||||
@ -20,13 +24,18 @@ export default function PhotoRecipe({
|
|||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||||
& EntityLinkExternalProps) {
|
& EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title="Recipe"
|
title="Recipe"
|
||||||
label={formatRecipe(recipe)}
|
label={formatRecipe(recipe)}
|
||||||
href={pathForRecipe(recipe)}
|
path={pathForRecipe(recipe)}
|
||||||
|
tooltipImagePath={pathForRecipeImage(recipe)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={<IconRecipe
|
icon={<IconRecipe
|
||||||
size={16}
|
size={16}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export default function RecipeHeader({
|
|||||||
entity={<PhotoRecipe
|
entity={<PhotoRecipe
|
||||||
recipe={recipe}
|
recipe={recipe}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
||||||
toggleRecipeOverlay={recipeProps
|
toggleRecipeOverlay={recipeProps
|
||||||
? () => setRecipeModalProps?.(recipeProps)
|
? () => setRecipeModalProps?.(recipeProps)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
|
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { descriptionForRecipePhotos, titleForRecipe } from '.';
|
import { descriptionForRecipePhotos, titleForRecipe } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ export default function RecipeOGTile({
|
|||||||
dateRange,
|
dateRange,
|
||||||
),
|
),
|
||||||
path: pathForRecipe(recipe),
|
path: pathForRecipe(recipe),
|
||||||
pathImageAbsolute: absolutePathForRecipeImage(recipe),
|
pathImage: pathForRecipeImage(recipe),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { TAG_FAVS } from '.';
|
import { TAG_FAVS } from '.';
|
||||||
import { pathForTag } from '@/app/paths';
|
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
import IconFavs from '@/components/icons/IconFavs';
|
import IconFavs from '@/components/icons/IconFavs';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function FavsTag({
|
export default function FavsTag({
|
||||||
type,
|
type,
|
||||||
@ -15,19 +17,24 @@ export default function FavsTag({
|
|||||||
}: {
|
}: {
|
||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
label={badged
|
label={TAG_FAVS}
|
||||||
? <span className="inline-flex gap-1 items-center">
|
labelComplex={badged &&
|
||||||
|
<span className="inline-flex gap-1 items-center">
|
||||||
{TAG_FAVS}
|
{TAG_FAVS}
|
||||||
<IconFavs
|
<IconFavs
|
||||||
size={10}
|
size={10}
|
||||||
className="translate-y-[-0.5px]"
|
className="translate-y-[-0.5px]"
|
||||||
highlight
|
highlight
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>}
|
||||||
: TAG_FAVS}
|
path={pathForTag(TAG_FAVS)}
|
||||||
href={pathForTag(TAG_FAVS)}
|
tooltipImagePath={pathForTagImage(TAG_FAVS)}
|
||||||
|
tooltipCaption={countOnHover &&
|
||||||
|
photoQuantityText(countOnHover, appText, false)}
|
||||||
icon={!badged &&
|
icon={!badged &&
|
||||||
<IconFavs
|
<IconFavs
|
||||||
size={13}
|
size={13}
|
||||||
|
|||||||
@ -17,16 +17,16 @@ export default function HiddenTag({
|
|||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
label={badged
|
label={TAG_HIDDEN}
|
||||||
? <span className="inline-flex items-center gap-1">
|
labelComplex={badged &&
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
{TAG_HIDDEN}
|
{TAG_HIDDEN}
|
||||||
<IconHidden
|
<IconHidden
|
||||||
size={13}
|
size={13}
|
||||||
className="translate-y-[-0.5px]"
|
className="translate-y-[-0.5px]"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>}
|
||||||
: TAG_HIDDEN}
|
path={pathForTag(TAG_HIDDEN)}
|
||||||
href={pathForTag(TAG_HIDDEN)}
|
|
||||||
icon={!badged && <IconHidden size={16} />}
|
icon={!badged && <IconHidden size={16} />}
|
||||||
type={type}
|
type={type}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { pathForTag } from '@/app/paths';
|
'use client';
|
||||||
|
|
||||||
|
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||||
import { formatTag } from '.';
|
import { formatTag } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/primitives/EntityLink';
|
||||||
import IconTag from '@/components/icons/IconTag';
|
import IconTag from '@/components/icons/IconTag';
|
||||||
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { photoQuantityText } from '@/photo';
|
||||||
|
|
||||||
export default function PhotoTag({
|
export default function PhotoTag({
|
||||||
tag,
|
tag,
|
||||||
@ -13,11 +17,16 @@ export default function PhotoTag({
|
|||||||
tag: string
|
tag: string
|
||||||
countOnHover?: number
|
countOnHover?: number
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatTag(tag)}
|
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]" />}
|
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
||||||
hoverEntity={countOnHover}
|
hoverEntity={countOnHover}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -26,8 +26,15 @@ export default async function TagHeader({
|
|||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
tag={tag}
|
tag={tag}
|
||||||
entity={isTagFavs(tag)
|
entity={isTagFavs(tag)
|
||||||
? <FavsTag contrast="high" />
|
? <FavsTag
|
||||||
: <PhotoTag tag={tag} contrast="high" />}
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>
|
||||||
|
: <PhotoTag
|
||||||
|
tag={tag}
|
||||||
|
contrast="high"
|
||||||
|
showTooltip={false}
|
||||||
|
/>}
|
||||||
entityVerb={appText.category.taggedPhotos}
|
entityVerb={appText.category.taggedPhotos}
|
||||||
entityDescription={descriptionForTaggedPhotos(
|
entityDescription={descriptionForTaggedPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { absolutePathForTagImage, pathForTag } from '@/app/paths';
|
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/OGTile';
|
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||||
import { descriptionForTaggedPhotos, titleForTag } from '.';
|
import { descriptionForTaggedPhotos, titleForTag } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export default function TagOGTile({
|
|||||||
dateRange,
|
dateRange,
|
||||||
),
|
),
|
||||||
path: pathForTag(tag),
|
path: pathForTag(tag),
|
||||||
pathImageAbsolute: absolutePathForTagImage(tag),
|
pathImage: pathForTagImage(tag),
|
||||||
loadingState: loadingStateExternal,
|
loadingState: loadingStateExternal,
|
||||||
onLoad,
|
onLoad,
|
||||||
onFail,
|
onFail,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user