Merge pull request #95 from sambecker/image-refactor

Refactor image components
This commit is contained in:
Sam Becker 2024-05-13 20:37:49 -05:00 committed by GitHub
commit 5ddb0e7111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 161 additions and 185 deletions

View File

@ -3,7 +3,7 @@
import { Photo, deleteConfirmationTextForPhoto, titleForPhoto } from '@/photo';
import AdminTable from './AdminTable';
import { Fragment } from 'react';
import PhotoTiny from '@/photo/PhotoTiny';
import PhotoSmall from '@/photo/PhotoSmall';
import { clsx } from 'clsx/lite';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/site/paths';
import Link from 'next/link';
@ -36,7 +36,7 @@ export default function AdminPhotosTable({
<AdminTable>
{photos.map((photo, index) =>
<Fragment key={photo.id}>
<PhotoTiny
<PhotoSmall
photo={photo}
onVisible={index === photos.length - 1
? onLastPhotoVisible

View File

@ -1,7 +1,6 @@
import { Fragment } from 'react';
import AdminTable from './AdminTable';
import Link from 'next/link';
import ImageTiny from '@/components/ImageTiny';
import { StorageListResponse, fileNameForStorageUrl } from '@/services/storage';
import FormWithConfirm from '@/components/FormWithConfirm';
import { deleteBlobPhotoAction } from '@/photo/actions';
@ -10,6 +9,7 @@ import { clsx } from 'clsx/lite';
import { pathForAdminUploadUrl } from '@/site/paths';
import AddButton from './AddButton';
import { formatDate } from 'date-fns';
import ImageSmall from '@/components/image/ImageSmall';
export default function AdminUploadsTable({
title,
@ -25,7 +25,7 @@ export default function AdminUploadsTable({
const uploadFileName = fileNameForStorageUrl(url);
return <Fragment key={url}>
<Link href={addUploadPath} prefetch={false}>
<ImageTiny
<ImageSmall
alt={`Upload: ${uploadFileName}`}
src={url}
aspectRatio={3.0 / 2.0}

View File

@ -36,7 +36,7 @@ import { signOutAndRedirectAction } from '@/auth/actions';
import { TbPhoto } from 'react-icons/tb';
import { getKeywordsForPhoto, titleForPhoto } from '@/photo';
import PhotoDate from '@/photo/PhotoDate';
import PhotoTiny from '@/photo/PhotoTiny';
import PhotoSmall from '@/photo/PhotoSmall';
import { FaCheck } from 'react-icons/fa6';
import { TagsWithMeta, addHiddenToTags } from '@/tag';
import { FaTag } from 'react-icons/fa';
@ -79,12 +79,12 @@ export default function CommandKClient({
hiddenPhotosCount,
arePhotosMatted,
shouldShowBaselineGrid,
shouldDebugBlur,
shouldDebugImageFallbacks,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid,
setArePhotosMatted,
setShouldDebugBlur,
setShouldDebugImageFallbacks,
} = useAppState();
const isOpenRef = useRef(isOpen);
@ -146,7 +146,7 @@ export default function CommandKClient({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo }} />,
accessory: <PhotoTiny photo={photo} />,
accessory: <PhotoSmall photo={photo} />,
path: pathForPhoto(photo),
})),
}]
@ -228,9 +228,11 @@ export default function CommandKClient({
action: () => setArePhotosMatted?.(prev => !prev),
annotation: arePhotosMatted ? <FaCheck size={12} /> : undefined,
}, {
label: 'Toggle Blur Debug',
action: () => setShouldDebugBlur?.(prev => !prev),
annotation: shouldDebugBlur ? <FaCheck size={12} /> : undefined,
label: 'Toggle Image Fallbacks',
action: () => setShouldDebugImageFallbacks?.(prev => !prev),
annotation: shouldDebugImageFallbacks
? <FaCheck size={12} />
: undefined,
}, {
label: 'Toggle Baseline Grid',
action: () => setShouldShowBaselineGrid?.(prev => !prev),

View File

@ -1,36 +0,0 @@
import { IMAGE_LARGE_WIDTH } from '@/site';
import ImageBlurFallback from './ImageBlurFallback';
export default function ImageLarge({
className,
imgClassName,
src,
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
priority,
}: {
className?: string
imgClassName?: string
src: string
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
priority?: boolean
}) {
return (
<ImageBlurFallback {...{
className,
imgClassName,
src,
alt,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
priority,
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />
);
};

View File

@ -1,33 +0,0 @@
import { IMAGE_SMALL_WIDTH } from '@/site';
import ImageBlurFallback from './ImageBlurFallback';
export default function ImageSmall({
className,
src,
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
priority,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
priority?: boolean
}) {
return (
<ImageBlurFallback {...{
className,
src,
alt,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
priority,
width: IMAGE_SMALL_WIDTH,
height: Math.round(IMAGE_SMALL_WIDTH / aspectRatio),
}} />
);
};

View File

@ -1,30 +0,0 @@
import { IMAGE_TINY_WIDTH } from '@/site';
import ImageBlurFallback from './ImageBlurFallback';
export default function ImageTiny({
className,
src,
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
}: {
className?: string
src: string
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
}) {
return (
<ImageBlurFallback {...{
className,
src,
alt,
blurDataURL: blurData,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
width: IMAGE_TINY_WIDTH,
height: Math.round(IMAGE_TINY_WIDTH / aspectRatio),
}} />
);
};

View File

@ -24,7 +24,7 @@ export default function ShareButton({
'-mx-0.5 translate-x-0.5',
'sm:mx-0 sm:translate-x-0',
)}
icon={<TbPhotoShare size={17} />}
icon={<TbPhotoShare size={16} />}
spinnerColor="dim"
prefetch={prefetch}
shouldScroll={shouldScroll}

View File

@ -0,0 +1,18 @@
import { IMAGE_WIDTH_LARGE, ImageProps } from '.';
import ImageWithFallback from './ImageWithFallback';
export default function ImageLarge(props: ImageProps) {
const {
aspectRatio,
blurCompatibilityMode,
...rest
} = props;
return (
<ImageWithFallback {...{
...rest,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
width: IMAGE_WIDTH_LARGE,
height: Math.round(IMAGE_WIDTH_LARGE / aspectRatio),
}} />
);
};

View File

@ -0,0 +1,18 @@
import { IMAGE_WIDTH_MEDIUM, ImageProps } from '.';
import ImageWithFallback from './ImageWithFallback';
export default function ImageMedium(props: ImageProps) {
const {
aspectRatio,
blurCompatibilityMode,
...rest
} = props;
return (
<ImageWithFallback {...{
...rest,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
width: IMAGE_WIDTH_MEDIUM,
height: Math.round(IMAGE_WIDTH_MEDIUM / aspectRatio),
}} />
);
};

View File

@ -0,0 +1,18 @@
import { IMAGE_WIDTH_SMALL, ImageProps } from '.';
import ImageWithFallback from './ImageWithFallback';
export default function ImageSmall(props: ImageProps) {
const {
aspectRatio,
blurCompatibilityMode,
...rest
} = props;
return (
<ImageWithFallback {...{
...rest,
blurCompatibilityLevel: blurCompatibilityMode ? 'high' : 'none',
width: IMAGE_WIDTH_SMALL,
height: Math.round(IMAGE_WIDTH_SMALL / aspectRatio),
}} />
);
};

View File

@ -7,7 +7,7 @@ import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
export default function ImageBlurFallback(props: ImageProps & {
export default function ImageWithFallback(props: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high'
imgClassName?: string
}) {
@ -20,7 +20,7 @@ export default function ImageBlurFallback(props: ImageProps & {
...rest
} = props;
const { shouldDebugBlur } = useAppState();
const { shouldDebugImageFallbacks } = useAppState();
const [wasCached, setWasCached] = useState(true);
const [isLoading, setIsLoading] = useState(true);
@ -29,7 +29,7 @@ export default function ImageBlurFallback(props: ImageProps & {
const onLoad = useCallback(() => setIsLoading(false), []);
const onError = useCallback(() => setDidError(true), []);
const [hideBlurPlaceholder, setHideBlurPlaceholder] = useState(false);
const [hideFallback, setHideFallback] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
@ -44,15 +44,15 @@ export default function ImageBlurFallback(props: ImageProps & {
useEffect(() => {
if (!isLoading && !didError) {
const timeout = setTimeout(() => {
setHideBlurPlaceholder(true);
setHideFallback(true);
}, 1000);
return () => clearTimeout(timeout);
}
}, [isLoading, didError]);
const showPlaceholder =
const showFallback =
!wasCached &&
!hideBlurPlaceholder;
!hideFallback;
const getBlurClass = () => {
switch (blurCompatibilityLevel) {
@ -71,16 +71,18 @@ export default function ImageBlurFallback(props: ImageProps & {
'flex relative',
)}
>
{(showPlaceholder || shouldDebugBlur) &&
{(showFallback || shouldDebugImageFallbacks) &&
<div className={clsx(
'@container',
'absolute inset-0',
'overflow-hidden',
'transition-opacity duration-300 ease-in',
!(BLUR_ENABLED && props.blurDataURL) && 'bg-main',
(isLoading || shouldDebugBlur) ? 'opacity-100' : 'opacity-0',
!(BLUR_ENABLED && blurDataURL) && 'bg-main',
(isLoading || shouldDebugImageFallbacks)
? 'opacity-100'
: 'opacity-0',
)}>
{(BLUR_ENABLED && props.blurDataURL)
{(BLUR_ENABLED && blurDataURL)
? <img {...{
...rest,
src: blurDataURL,

View File

@ -0,0 +1,17 @@
// Height determined by intrinsic photo aspect ratio
export const IMAGE_WIDTH_SMALL = 50;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_WIDTH_MEDIUM = 300;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_WIDTH_LARGE = 1000;
export interface ImageProps {
aspectRatio: number
blurCompatibilityMode?: boolean
className?: string
imgClassName?: string
src: string
alt: string
blurDataURL?: string
priority?: boolean
}

View File

@ -1,5 +1,5 @@
import { Photo } from '.';
import PhotoSmall from './PhotoSmall';
import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';
import { Camera } from '@/camera';
@ -57,7 +57,7 @@ export default function PhotoGrid({
<div
key={photo.id}
className={GRID_ASPECT_RATIO !== 0
? 'aspect-square overflow-hidden'
? 'flex relative overflow-hidden'
: undefined}
style={{
...GRID_ASPECT_RATIO !== 0 && {
@ -65,17 +65,20 @@ export default function PhotoGrid({
},
}}
>
<PhotoSmall {...{
photo,
tag,
camera,
simulation,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}} />
<PhotoMedium
className="flex w-full h-full"
{...{
photo,
tag,
camera,
simulation,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,
}}
/>
</div>).concat(additionalTile ?? [])}
itemKeys={photos.map(photo => photo.id)
.concat(additionalTile ? ['more'] : [])}

View File

@ -8,7 +8,7 @@ import {
shouldShowExifDataForPhoto,
} from '.';
import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/ImageLarge';
import ImageLarge from '@/components/image/ImageLarge';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { pathForPhoto, pathForPhotoShare } from '@/site/paths';
@ -96,7 +96,7 @@ export default function PhotoLarge({
alt={altTextForPhoto(photo)}
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>

View File

@ -1,25 +1,35 @@
'use client';
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
import ImageTiny from '@/components/ImageTiny';
import ImageMedium from '@/components/image/ImageMedium';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
export default function PhotoTiny({
export default function PhotoMedium({
photo,
tag,
camera,
simulation,
selected,
className,
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
className,
onVisible,
}: {
photo: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
selected?: boolean
className?: string
priority?: boolean
prefetch?: boolean
className?: string
onVisible?: () => void
}) {
const ref = useRef<HTMLAnchorElement>(null);
@ -29,23 +39,23 @@ export default function PhotoTiny({
return (
<Link
ref={ref}
href={pathForPhoto(photo, tag)}
href={pathForPhoto(photo, tag, camera, simulation)}
className={clsx(
className,
'active:brightness-75',
selected && 'brightness-50',
'min-w-[50px]',
'rounded-[0.15rem] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
className,
)}
prefetch={prefetch}
>
<ImageTiny
<ImageMedium
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="flex object-cover w-full h-full"
imgClassName="object-cover w-full h-full"
alt={altTextForPhoto(photo)}
priority={priority}
/>
</Link>
);

View File

@ -1,12 +1,8 @@
'use client';
import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.';
import ImageSmall from '@/components/ImageSmall';
import ImageSmall from '@/components/image/ImageSmall';
import Link from 'next/link';
import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible';
@ -14,19 +10,15 @@ import useOnVisible from '@/utility/useOnVisible';
export default function PhotoSmall({
photo,
tag,
camera,
simulation,
selected,
priority,
className,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
onVisible,
}: {
photo: Photo
tag?: string
camera?: Camera
simulation?: FilmSimulation
selected?: boolean
priority?: boolean
className?: string
prefetch?: boolean
onVisible?: () => void
}) {
@ -37,22 +29,23 @@ export default function PhotoSmall({
return (
<Link
ref={ref}
href={pathForPhoto(photo, tag, camera, simulation)}
href={pathForPhoto(photo, tag)}
className={clsx(
'flex w-full h-full',
className,
'active:brightness-75',
selected && 'brightness-50',
'min-w-[50px]',
'rounded-[0.15rem] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
prefetch={prefetch}
>
<ImageSmall
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
className="w-full"
alt={altTextForPhoto(photo)}
priority={priority}
/>
</Link>
);

View File

@ -18,7 +18,7 @@ import { clsx } from 'clsx/lite';
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
import ImageWithFallback from '@/components/image/ImageWithFallback';
import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import { AiContent } from '../ai/useAiImageQueries';
@ -59,7 +59,7 @@ export default function PhotoForm({
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
const { invalidateSwr, shouldDebugBlur } = useAppState();
const { invalidateSwr, shouldDebugImageFallbacks } = useAppState();
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
@ -199,7 +199,7 @@ export default function PhotoForm({
shouldConfirm={Boolean(formData.semanticDescription)}
/>;
case 'blurData':
return shouldDebugBlur && type === 'edit' && formData.url
return shouldDebugImageFallbacks && type === 'edit' && formData.url
? <UpdateBlurDataButton
photoUrl={getNextImageUrlForManipulation(formData.url)}
onUpdatedBlurData={blurData =>
@ -219,7 +219,7 @@ export default function PhotoForm({
key === 'blurData' &&
type === 'create' &&
!BLUR_ENABLED &&
!shouldDebugBlur
!shouldDebugImageFallbacks
) {
return true;
} else {
@ -234,7 +234,7 @@ export default function PhotoForm({
<div className="space-y-8 max-w-[38rem] relative">
<div className="flex gap-2">
<div className="relative">
<ImageBlurFallback
<ImageWithFallback
alt="Upload"
src={url}
className={clsx(
@ -307,9 +307,11 @@ export default function PhotoForm({
<FieldSetWithStatus
key={key}
id={key}
label={label + (key === 'blurData' && shouldDebugBlur
? ` (${(formData[key] ?? '').length} chars.)`
: '')}
label={label + (
key === 'blurData' && shouldDebugImageFallbacks
? ` (${(formData[key] ?? '').length} chars.)`
: ''
)}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}

View File

@ -1,8 +0,0 @@
// Height determined by intrinsic photo aspect ratio
export const IMAGE_TINY_WIDTH = 50;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_SMALL_WIDTH = 300;
// Height determined by intrinsic photo aspect ratio
export const IMAGE_LARGE_WIDTH = 1000;

View File

@ -25,8 +25,8 @@ export interface AppStateContext {
// DEBUG
arePhotosMatted?: boolean
setArePhotosMatted?: Dispatch<SetStateAction<boolean>>
shouldDebugBlur?: boolean
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
shouldDebugImageFallbacks?: boolean
setShouldDebugImageFallbacks?: Dispatch<SetStateAction<boolean>>
shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
}

View File

@ -37,7 +37,7 @@ export default function AppStateProvider({
// DEBUG
const [arePhotosMatted, setArePhotosMatted] =
useState(MATTE_PHOTOS);
const [shouldDebugBlur, setShouldDebugBlur] =
const [shouldDebugImageFallbacks, setShouldDebugImageFallbacks] =
useState(false);
const [shouldShowBaselineGrid, setShouldShowBaselineGrid] =
useState(false);
@ -96,10 +96,10 @@ export default function AppStateProvider({
// DEBUG
arePhotosMatted,
setArePhotosMatted,
setShouldDebugBlur,
setShouldShowBaselineGrid,
shouldDebugImageFallbacks,
setShouldDebugImageFallbacks,
shouldShowBaselineGrid,
shouldDebugBlur,
setShouldShowBaselineGrid,
}}
>
{children}