Merge pull request #167 from sambecker/thumbnail-loader

Show loading status in more places
This commit is contained in:
Sam Becker 2025-01-20 14:12:08 -06:00 committed by GitHub
commit ebbf01c698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 45 additions and 17 deletions

View File

@ -14,6 +14,7 @@ const eslintConfig = [
rules: { rules: {
'@next/next/no-img-element': 'off', '@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-unused-expressions': ['warn'], 'no-unused-expressions': ['warn'],
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', { 'warn', {

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import { RemotePattern } from 'next/dist/shared/lib/image-config'; import { RemotePattern } from 'next/dist/shared/lib/image-config';

View File

@ -17,10 +17,15 @@ const FLICKER_THRESHOLD = 400;
// Clear loading status after long duration // Clear loading status after long duration
const MAX_LOADING_DURATION = 15_000; const MAX_LOADING_DURATION = 15_000;
export type LinkWithStatusProps = ComponentProps<typeof Link> & { export type LinkWithStatusProps = Omit<
ComponentProps<typeof Link>, 'children'
> & {
loadingElement?: ReactNode loadingElement?: ReactNode
loadingClassName?: string loadingClassName?: string
contentClassName?: string contentClassName?: string
children: ReactNode | ((props: {
isLoading: boolean
}) => ReactNode)
} }
export default function LinkWithStatus({ export default function LinkWithStatus({
@ -44,6 +49,8 @@ export default function LinkWithStatus({
const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined); const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined); const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const isControlled = typeof children === 'function';
const clearTimeouts = useCallback(() => { const clearTimeouts = useCallback(() => {
[startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout] [startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout]
.forEach(timeout => { .forEach(timeout => {
@ -114,11 +121,13 @@ export default function LinkWithStatus({
contentClassName, contentClassName,
loadingElement loadingElement
? isLoading ? 'opacity-0' : 'opacity-100' ? isLoading ? 'opacity-0' : 'opacity-100'
: loadingClassName : (loadingClassName || isControlled)
? 'opacity-100' ? 'opacity-100'
: isLoading ? 'opacity-50' : 'opacity-100', : isLoading ? 'opacity-50' : 'opacity-100',
)}> )}>
{children} {typeof children === 'function'
? children({ isLoading })
: children}
</span> </span>
{isLoading && loadingElement && <span className={clsx( {isLoading && loadingElement && <span className={clsx(
'absolute inset-0', 'absolute inset-0',

View File

@ -7,12 +7,13 @@ import {
doesPhotoNeedBlurCompatibility, doesPhotoNeedBlurCompatibility,
} from '.'; } from '.';
import ImageMedium from '@/components/image/ImageMedium'; import ImageMedium from '@/components/image/ImageMedium';
import Link from 'next/link';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config'; import { SHOULD_PREFETCH_ALL_LINKS } from '@/site/config';
import { useRef } from 'react'; import { useRef } from 'react';
import useOnVisible from '@/utility/useOnVisible'; import useOnVisible from '@/utility/useOnVisible';
import LinkWithStatus from '@/components/LinkWithStatus';
import Spinner from '@/components/Spinner';
export default function PhotoMedium({ export default function PhotoMedium({
photo, photo,
@ -38,7 +39,7 @@ export default function PhotoMedium({
useOnVisible(ref, onVisible); useOnVisible(ref, onVisible);
return ( return (
<Link <LinkWithStatus
ref={ref} ref={ref}
href={pathForPhoto({ photo, tag, camera, simulation, focal })} href={pathForPhoto({ photo, tag, camera, simulation, focal })}
className={clsx( className={clsx(
@ -48,16 +49,28 @@ export default function PhotoMedium({
)} )}
prefetch={prefetch} prefetch={prefetch}
> >
<ImageMedium {({ isLoading }) =>
src={photo.url} <div>
aspectRatio={photo.aspectRatio} {isLoading &&
blurDataURL={photo.blurData} <div className={clsx(
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} 'absolute inset-0 flex items-center justify-center',
className="flex object-cover w-full h-full" 'text-white bg-black/25 backdrop-blur-sm',
imgClassName="object-cover w-full h-full" 'animate-fade-in',
alt={altTextForPhoto(photo)} 'z-10',
priority={priority} )}>
/> <Spinner size={20} color="text" />
</Link> </div>}
<ImageMedium
src={photo.url}
aspectRatio={photo.aspectRatio}
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}
/>
</div>}
</LinkWithStatus>
); );
}; };

View File

@ -28,12 +28,18 @@ module.exports = {
animation: { animation: {
'rotate-pulse': 'rotate-pulse':
'rotate-pulse 0.75s linear infinite normal both running', 'rotate-pulse 0.75s linear infinite normal both running',
'fade-in':
'fade-in 0.5s linear',
'hover-drift': 'hover-drift':
'hover-drift 8s linear infinite', 'hover-drift 8s linear infinite',
'hover-wobble': 'hover-wobble':
'hover-wobble 6s linear infinite normal both running', 'hover-wobble 6s linear infinite normal both running',
}, },
keyframes: { keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'rotate-pulse': { 'rotate-pulse': {
'0%': { transform: 'rotate(0deg) scale(1)' }, '0%': { transform: 'rotate(0deg) scale(1)' },
'50%': { transform: 'rotate(180deg) scale(0.8)' }, '50%': { transform: 'rotate(180deg) scale(0.8)' },