Improve tooltip segues (#272)
* Update GH issue template * Create custom tooltip display engine * Fix tooltip cleanup behavior * Make tooltip position size-aware * Refine tooltip og positioning * Refine og tooltip behavior * Refine og image loading behavior
This commit is contained in:
parent
9a3b60bcd9
commit
d2e62a9091
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -7,10 +7,10 @@ assignees: ''
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem?**
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the feature/solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Live deployment**
|
**Live deployment**
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { revalidatePath } from 'next/cache';
|
|||||||
import RecipeModal from '@/recipe/RecipeModal';
|
import RecipeModal from '@/recipe/RecipeModal';
|
||||||
import ThemeColors from '@/app/ThemeColors';
|
import ThemeColors from '@/app/ThemeColors';
|
||||||
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
||||||
|
import OGTooltipProvider from '@/components/og/OGTooltipProvider';
|
||||||
|
|
||||||
import '../tailwind.css';
|
import '../tailwind.css';
|
||||||
|
|
||||||
@ -95,6 +96,7 @@ export default function RootLayout({
|
|||||||
<ThemeColors />
|
<ThemeColors />
|
||||||
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
||||||
<SwrConfigClient>
|
<SwrConfigClient>
|
||||||
|
<OGTooltipProvider>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'mx-3 mb-3',
|
'mx-3 mb-3',
|
||||||
'lg:mx-6 lg:mb-6',
|
'lg:mx-6 lg:mb-6',
|
||||||
@ -132,6 +134,7 @@ export default function RootLayout({
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
<CommandK />
|
<CommandK />
|
||||||
|
</OGTooltipProvider>
|
||||||
</SwrConfigClient>
|
</SwrConfigClient>
|
||||||
<Analytics debug={false} />
|
<Analytics debug={false} />
|
||||||
<SpeedInsights debug={false} />
|
<SpeedInsights debug={false} />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } 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';
|
||||||
@ -10,29 +10,22 @@ import { useAppText } from '@/i18n/state/client';
|
|||||||
export default function CameraOGTile({
|
export default function CameraOGTile({
|
||||||
camera,
|
camera,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
camera: Camera
|
camera: Camera
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForCamera(camera, photos, appText, count),
|
title: titleForCamera(camera, photos, appText, count),
|
||||||
description: descriptionForCameraPhotos(
|
description:
|
||||||
|
descriptionForCameraPhotos(
|
||||||
photos,
|
photos,
|
||||||
appText,
|
appText,
|
||||||
true,
|
true,
|
||||||
@ -41,11 +34,6 @@ export default function CameraOGTile({
|
|||||||
),
|
),
|
||||||
path: pathForCamera(camera),
|
path: pathForCamera(camera),
|
||||||
pathImage: pathForCameraImage(camera),
|
pathImage: pathForCameraImage(camera),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,49 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
||||||
import { TbPhotoQuestion } from 'react-icons/tb';
|
import { TbPhotoQuestion } from 'react-icons/tb';
|
||||||
|
|
||||||
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
|
type LoadingState = 'loading' | 'loaded' | 'failed';
|
||||||
|
|
||||||
export default function OGLoaderImage({
|
export default function OGLoaderImage({
|
||||||
title,
|
title,
|
||||||
path,
|
path,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
retryTime,
|
||||||
className,
|
className,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
path: string
|
path: string
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
retryTime?: number
|
retryTime?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
enabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const ref = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
const [loadingStateInternal, setLoadingStateInternal] =
|
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
|
||||||
useState(loadingStateExternal ?? 'unloaded');
|
|
||||||
|
|
||||||
const loadingState = loadingStateExternal ?? loadingStateInternal;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!loadingStateExternal &&
|
|
||||||
loadingStateInternal === 'unloaded'
|
|
||||||
) {
|
|
||||||
setLoadingStateInternal('loading');
|
|
||||||
}
|
|
||||||
}, [loadingStateExternal, loadingStateInternal]);
|
|
||||||
|
|
||||||
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
|
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current?.complete) {
|
||||||
|
setLoadingState('loading');
|
||||||
|
}
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={path}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative',
|
'relative',
|
||||||
className,
|
className,
|
||||||
@ -67,6 +59,7 @@ export default function OGLoaderImage({
|
|||||||
</div>}
|
</div>}
|
||||||
{(loadingState === 'loading' || loadingState === 'loaded') &&
|
{(loadingState === 'loading' || loadingState === 'loaded') &&
|
||||||
<img
|
<img
|
||||||
|
ref={ref}
|
||||||
alt={title}
|
alt={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute top-0 left-0 right-0 bottom-0 z-0',
|
'absolute top-0 left-0 right-0 bottom-0 z-0',
|
||||||
@ -74,25 +67,18 @@ export default function OGLoaderImage({
|
|||||||
loadingState === 'loading' && 'opacity-0',
|
loadingState === 'loading' && 'opacity-0',
|
||||||
'transition-opacity',
|
'transition-opacity',
|
||||||
)}
|
)}
|
||||||
src={path}
|
src={enabled ? path : ''}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLoad={() => {
|
onLoadStart={() => setLoadingState('loading')}
|
||||||
if (onLoad) {
|
onLoad={() => setLoadingState('loaded')}
|
||||||
onLoad();
|
onError={e => {
|
||||||
} else {
|
setLoadingState('failed');
|
||||||
setLoadingStateInternal('loaded');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
if (onFail) {
|
|
||||||
onFail();
|
|
||||||
} else {
|
|
||||||
setLoadingStateInternal('failed');
|
|
||||||
}
|
|
||||||
if (retryTime !== undefined) {
|
if (retryTime !== undefined) {
|
||||||
|
setLoadingState('loading');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLoadingStateInternal('loading');
|
e.currentTarget.src = '';
|
||||||
|
e.currentTarget.src = path;
|
||||||
}, retryTime);
|
}, retryTime);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import Link from 'next/link';
|
|||||||
import useVisible from '@/utility/useVisible';
|
import useVisible from '@/utility/useVisible';
|
||||||
import OGLoaderImage from './OGLoaderImage';
|
import OGLoaderImage from './OGLoaderImage';
|
||||||
|
|
||||||
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
|
export type OGTilePropsCore = Omit<
|
||||||
|
ComponentProps<typeof OGTile>,
|
||||||
|
'title' | 'description' | 'path' | 'pathImage'
|
||||||
|
>;
|
||||||
|
|
||||||
export default function OGTile({
|
export default function OGTile({
|
||||||
path,
|
path,
|
||||||
|
|||||||
@ -1,8 +1,16 @@
|
|||||||
import { ComponentProps, ReactNode } from 'react';
|
import { ComponentProps, ReactNode, useRef, useEffect } from 'react';
|
||||||
import TooltipPrimitive from '../primitives/TooltipPrimitive';
|
|
||||||
import OGLoaderImage from './OGLoaderImage';
|
import OGLoaderImage from './OGLoaderImage';
|
||||||
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
|
import { useOGTooltipState } from './state';
|
||||||
|
import useSupportsHover from '@/utility/useSupportsHover';
|
||||||
|
|
||||||
|
const { aspectRatio } = IMAGE_OG_DIMENSION;
|
||||||
|
|
||||||
|
const width = 300;
|
||||||
|
const height = width / aspectRatio;
|
||||||
|
const offsetAbove = -1;
|
||||||
|
const offsetBelow = -6;
|
||||||
|
|
||||||
export default function OGTooltip({
|
export default function OGTooltip({
|
||||||
children,
|
children,
|
||||||
@ -12,15 +20,21 @@ export default function OGTooltip({
|
|||||||
children :ReactNode
|
children :ReactNode
|
||||||
caption?: ReactNode
|
caption?: ReactNode
|
||||||
} & ComponentProps<typeof OGLoaderImage>) {
|
} & ComponentProps<typeof OGLoaderImage>) {
|
||||||
const { aspectRatio } = IMAGE_OG_DIMENSION;
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
return (
|
|
||||||
<TooltipPrimitive
|
const { showTooltip, dismissTooltip } = useOGTooltipState();
|
||||||
className="max-w-none p-1!"
|
|
||||||
classNameTrigger="max-w-full"
|
const supportsHover = useSupportsHover();
|
||||||
disableHoverableContent
|
|
||||||
content={<div
|
useEffect(() => {
|
||||||
|
const trigger = ref.current;
|
||||||
|
return () => dismissTooltip?.(trigger);
|
||||||
|
}, [dismissTooltip]);
|
||||||
|
|
||||||
|
const content =
|
||||||
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
style={{ width: 300, aspectRatio }}
|
style={{ width, height }}
|
||||||
>
|
>
|
||||||
<OGLoaderImage
|
<OGLoaderImage
|
||||||
{...props}
|
{...props}
|
||||||
@ -38,9 +52,21 @@ export default function OGTooltip({
|
|||||||
)}>
|
)}>
|
||||||
{caption}
|
{caption}
|
||||||
</div>}
|
</div>}
|
||||||
</div>}
|
</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-w-full"
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={() => supportsHover &&
|
||||||
|
showTooltip?.(
|
||||||
|
ref.current,
|
||||||
|
{ content, width, height, offsetAbove, offsetBelow },
|
||||||
|
)}
|
||||||
|
onMouseLeave={() => supportsHover &&
|
||||||
|
dismissTooltip?.(ref.current)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TooltipPrimitive>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/components/og/OGTooltipProvider.tsx
Normal file
131
src/components/og/OGTooltipProvider.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { OGTooltipContext, Tooltip } from './state';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import MenuSurface from '../primitives/MenuSurface';
|
||||||
|
|
||||||
|
const DELAY_INITIAL_HOVER = 200;
|
||||||
|
const DELAY_DISMISS = 200;
|
||||||
|
|
||||||
|
const VIEWPORT_SAFE_AREA = 12;
|
||||||
|
const TOOLTIP_MARGIN = 12;
|
||||||
|
|
||||||
|
export default function OGTooltipProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [currentTooltip, setCurrentTooltip] = useState<Tooltip>();
|
||||||
|
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties>();
|
||||||
|
|
||||||
|
const currentTriggerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const timeoutInitialHoverRef = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
const timeoutDismissRef = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
|
const clearTimeouts = useCallback(() => {
|
||||||
|
clearTimeout(timeoutInitialHoverRef.current);
|
||||||
|
timeoutInitialHoverRef.current = undefined;
|
||||||
|
clearTimeout(timeoutDismissRef.current);
|
||||||
|
timeoutDismissRef.current = undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearState = useCallback((delay = 0) => {
|
||||||
|
clearTimeouts();
|
||||||
|
if (delay) {
|
||||||
|
timeoutDismissRef.current = setTimeout(() => {
|
||||||
|
setCurrentTooltip(undefined);
|
||||||
|
currentTriggerRef.current = null;
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
setCurrentTooltip(undefined);
|
||||||
|
currentTriggerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [clearTimeouts]);
|
||||||
|
|
||||||
|
const showTooltip = useCallback((
|
||||||
|
_trigger: HTMLElement | null,
|
||||||
|
tooltip: Tooltip,
|
||||||
|
) => {
|
||||||
|
if (_trigger) {
|
||||||
|
currentTriggerRef.current = _trigger;
|
||||||
|
const displayTooltip = () => {
|
||||||
|
// Update current trigger ref on display
|
||||||
|
currentTriggerRef.current = _trigger;
|
||||||
|
setCurrentTooltip(tooltip);
|
||||||
|
const trigger = _trigger.getBoundingClientRect();
|
||||||
|
const top =
|
||||||
|
trigger.top - (tooltip.height + TOOLTIP_MARGIN) < VIEWPORT_SAFE_AREA
|
||||||
|
// Position below trigger
|
||||||
|
? trigger.bottom + TOOLTIP_MARGIN + tooltip.offsetBelow
|
||||||
|
// Position above trigger
|
||||||
|
: trigger.top - (tooltip.height + TOOLTIP_MARGIN)
|
||||||
|
+ tooltip.offsetAbove;
|
||||||
|
const horizontalOffset =
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
window.innerWidth - (trigger.left + tooltip.width) < VIEWPORT_SAFE_AREA
|
||||||
|
? { right: VIEWPORT_SAFE_AREA }
|
||||||
|
: { left: trigger.left };
|
||||||
|
setTooltipStyle({ top, ...horizontalOffset });
|
||||||
|
clearTimeouts();
|
||||||
|
};
|
||||||
|
if (currentTooltip) {
|
||||||
|
// Don't apply delay if tooltip's already visible
|
||||||
|
displayTooltip();
|
||||||
|
} else {
|
||||||
|
timeoutInitialHoverRef.current =
|
||||||
|
setTimeout(displayTooltip, DELAY_INITIAL_HOVER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTooltip, clearTimeouts]);
|
||||||
|
|
||||||
|
const dismissTooltip = useCallback((trigger: HTMLElement | null) => {
|
||||||
|
if (trigger === currentTriggerRef.current) {
|
||||||
|
clearState(DELAY_DISMISS);
|
||||||
|
}
|
||||||
|
}, [clearState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onWindowChange = () => clearState(0);
|
||||||
|
window.addEventListener('mouseup', onWindowChange);
|
||||||
|
window.addEventListener('mousewheel', onWindowChange);
|
||||||
|
window.addEventListener('resize', onWindowChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mouseup', onWindowChange);
|
||||||
|
window.removeEventListener('mousewheel', onWindowChange);
|
||||||
|
window.removeEventListener('resize', onWindowChange);
|
||||||
|
};
|
||||||
|
}, [clearState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OGTooltipContext.Provider value={{ showTooltip, dismissTooltip }}>
|
||||||
|
<div className="relative inset-0 z-50 pointer-events-none">
|
||||||
|
<AnimatePresence>
|
||||||
|
{currentTooltip &&
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
layoutId="tooltip"
|
||||||
|
className="fixed"
|
||||||
|
style={tooltipStyle}
|
||||||
|
>
|
||||||
|
<MenuSurface className="max-w-none p-1!">
|
||||||
|
{currentTooltip.content}
|
||||||
|
</MenuSurface>
|
||||||
|
</motion.div>}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</OGTooltipContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/og/state.ts
Normal file
18
src/components/og/state.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createContext, ReactNode, use } from 'react';
|
||||||
|
|
||||||
|
export type Tooltip = {
|
||||||
|
content: ReactNode
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
offsetAbove: number
|
||||||
|
offsetBelow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OGTooltipState = {
|
||||||
|
showTooltip?: (trigger: HTMLElement | null, tooltip: Tooltip) => void
|
||||||
|
dismissTooltip?: (trigger: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OGTooltipContext = createContext<OGTooltipState>({});
|
||||||
|
|
||||||
|
export const useOGTooltipState = () => use(OGTooltipContext);
|
||||||
@ -5,44 +5,31 @@ import {
|
|||||||
pathForFilm,
|
pathForFilm,
|
||||||
pathForFilmImage,
|
pathForFilmImage,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function FilmOGTile({
|
export default function FilmOGTile({
|
||||||
film,
|
film,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
film: string
|
film: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForFilm(film, photos, appText, count),
|
title: titleForFilm(film, photos, appText, count),
|
||||||
description:
|
description:
|
||||||
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
||||||
path: pathForFilm(film),
|
path: pathForFilm(film),
|
||||||
pathImage: pathForFilmImage(film),
|
pathImage: pathForFilmImage(film),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,36 +5,29 @@ import {
|
|||||||
pathForFocalLength,
|
pathForFocalLength,
|
||||||
pathForFocalLengthImage,
|
pathForFocalLengthImage,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function FocalLengthOGTile({
|
export default function FocalLengthOGTile({
|
||||||
focal,
|
focal,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
focal: number
|
focal: number
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForFocalLength(focal, photos, appText, count),
|
title: titleForFocalLength(focal, photos, appText, count),
|
||||||
description: descriptionForFocalLengthPhotos(
|
description:
|
||||||
|
descriptionForFocalLengthPhotos(
|
||||||
photos,
|
photos,
|
||||||
appText,
|
appText,
|
||||||
true,
|
true,
|
||||||
@ -43,11 +36,6 @@ export default function FocalLengthOGTile({
|
|||||||
),
|
),
|
||||||
path: pathForFocalLength(focal),
|
path: pathForFocalLength(focal),
|
||||||
pathImage: pathForFocalLengthImage(focal),
|
pathImage: pathForFocalLengthImage(focal),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -101,6 +101,7 @@ export const TEXT = {
|
|||||||
setupIncomplete: 'সেটআপ সম্পূর্ণ করুন',
|
setupIncomplete: 'সেটআপ সম্পূর্ণ করুন',
|
||||||
setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন',
|
setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন',
|
||||||
setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন',
|
setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন',
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
|
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
|
||||||
},
|
},
|
||||||
misc: {
|
misc: {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } 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';
|
||||||
@ -8,27 +8,19 @@ import { useAppText } from '@/i18n/state/client';
|
|||||||
export default function LensOGTile({
|
export default function LensOGTile({
|
||||||
lens,
|
lens,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
lens: Lens
|
lens: Lens
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForLens(lens, photos, appText, count),
|
title: titleForLens(lens, photos, appText, count),
|
||||||
description: descriptionForLensPhotos(
|
description: descriptionForLensPhotos(
|
||||||
photos,
|
photos,
|
||||||
@ -39,11 +31,6 @@ export default function LensOGTile({
|
|||||||
),
|
),
|
||||||
path: pathForLens(lens),
|
path: pathForLens(lens),
|
||||||
pathImage: pathForLensImage(lens),
|
pathImage: pathForLensImage(lens),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,35 +7,23 @@ import {
|
|||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
|
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
|
|
||||||
export default function PhotoOGTile({
|
export default function PhotoOGTile({
|
||||||
photo,
|
photo,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
riseOnHover,
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
retryTime,
|
||||||
onVisible,
|
onVisible,
|
||||||
...categories
|
...categories
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
loadingState?: OGLoadingState
|
} & PhotoSetCategory & OGTilePropsCore) {
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
onVisible?: () => void
|
|
||||||
} & PhotoSetCategory) {
|
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
title: titleForPhoto(photo),
|
title: titleForPhoto(photo),
|
||||||
description: descriptionForPhoto(photo),
|
description: descriptionForPhoto(photo),
|
||||||
path: pathForPhoto({ photo, ...categories }),
|
path: pathForPhoto({ photo, ...categories }),
|
||||||
pathImage: pathForPhotoImage(photo),
|
pathImage: pathForPhotoImage(photo),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
riseOnHover,
|
||||||
retryTime,
|
retryTime,
|
||||||
onVisible,
|
onVisible,
|
||||||
|
|||||||
@ -1,69 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
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/og/OGTile';
|
|
||||||
|
|
||||||
const DEFAULT_MAX_CONCURRENCY = 3;
|
|
||||||
|
|
||||||
type PhotoLoadingState = Record<string, OGLoadingState>;
|
|
||||||
|
|
||||||
export default function StaggeredOgPhotos({
|
export default function StaggeredOgPhotos({
|
||||||
photos,
|
photos,
|
||||||
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
|
|
||||||
onLastPhotoVisible,
|
onLastPhotoVisible,
|
||||||
}: {
|
}: {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
maxConcurrency?: number
|
maxConcurrency?: number
|
||||||
onLastPhotoVisible?: () => void
|
onLastPhotoVisible?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [loadingState, setLoadingState] = useState(
|
|
||||||
photos.reduce((acc, photo) => ({
|
|
||||||
...acc,
|
|
||||||
[photo.id]: 'unloaded' as const,
|
|
||||||
}), {} as PhotoLoadingState),
|
|
||||||
);
|
|
||||||
|
|
||||||
const recomputeLoadingState = useCallback((
|
|
||||||
updatedState: PhotoLoadingState = {},
|
|
||||||
) => setLoadingState(currentLoadingState => {
|
|
||||||
const initialLoadingState = {
|
|
||||||
...currentLoadingState,
|
|
||||||
...updatedState,
|
|
||||||
};
|
|
||||||
const updatedLoadingState = {
|
|
||||||
...currentLoadingState,
|
|
||||||
...updatedState,
|
|
||||||
};
|
|
||||||
|
|
||||||
let imagesLoadingCount = 0;
|
|
||||||
Object.entries(initialLoadingState).forEach(([id, state]) => {
|
|
||||||
if (state === 'loading') {
|
|
||||||
imagesLoadingCount++;
|
|
||||||
} else if (imagesLoadingCount < maxConcurrency && state === 'unloaded') {
|
|
||||||
updatedLoadingState[id] = 'loading';
|
|
||||||
imagesLoadingCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedLoadingState;
|
|
||||||
})
|
|
||||||
, [maxConcurrency]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
recomputeLoadingState();
|
|
||||||
}, [recomputeLoadingState]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{photos.map((photo, index) =>
|
{photos.map((photo, index) =>
|
||||||
<PhotoOGTile
|
<PhotoOGTile
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
loadingState={loadingState[photo.id]}
|
|
||||||
onLoad={() => recomputeLoadingState({ [photo.id]: 'loaded' })}
|
|
||||||
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
|
|
||||||
onVisible={index === photos.length - 1
|
onVisible={index === photos.length - 1
|
||||||
? onLastPhotoVisible
|
? onLastPhotoVisible
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@ -1,33 +1,25 @@
|
|||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForRecipePhotos, titleForRecipe } from '.';
|
import { descriptionForRecipePhotos, titleForRecipe } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function RecipeOGTile({
|
export default function RecipeOGTile({
|
||||||
recipe,
|
recipe,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
recipe: string
|
recipe: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForRecipe(recipe, photos, appText, count),
|
title: titleForRecipe(recipe, photos, appText, count),
|
||||||
description: descriptionForRecipePhotos(
|
description: descriptionForRecipePhotos(
|
||||||
photos,
|
photos,
|
||||||
@ -38,11 +30,6 @@ export default function RecipeOGTile({
|
|||||||
),
|
),
|
||||||
path: pathForRecipe(recipe),
|
path: pathForRecipe(recipe),
|
||||||
pathImage: pathForRecipeImage(recipe),
|
pathImage: pathForRecipeImage(recipe),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,34 +2,26 @@
|
|||||||
|
|
||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||||
import { descriptionForTaggedPhotos, titleForTag } from '.';
|
import { descriptionForTaggedPhotos, titleForTag } from '.';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function TagOGTile({
|
export default function TagOGTile({
|
||||||
tag,
|
tag,
|
||||||
photos,
|
photos,
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
riseOnHover,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
retryTime,
|
|
||||||
count,
|
count,
|
||||||
dateRange,
|
dateRange,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
tag: string
|
tag: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
loadingState?: OGLoadingState
|
|
||||||
onLoad?: () => void
|
|
||||||
onFail?: () => void
|
|
||||||
riseOnHover?: boolean
|
|
||||||
retryTime?: number
|
|
||||||
count?: number
|
count?: number
|
||||||
dateRange?: PhotoDateRange
|
dateRange?: PhotoDateRange
|
||||||
}) {
|
} & OGTilePropsCore) {
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
return (
|
return (
|
||||||
<OGTile {...{
|
<OGTile {...{
|
||||||
|
...props,
|
||||||
title: titleForTag(tag, photos, appText, count),
|
title: titleForTag(tag, photos, appText, count),
|
||||||
description: descriptionForTaggedPhotos(
|
description: descriptionForTaggedPhotos(
|
||||||
photos,
|
photos,
|
||||||
@ -40,11 +32,6 @@ export default function TagOGTile({
|
|||||||
),
|
),
|
||||||
path: pathForTag(tag),
|
path: pathForTag(tag),
|
||||||
pathImage: pathForTagImage(tag),
|
pathImage: pathForTagImage(tag),
|
||||||
loadingState: loadingStateExternal,
|
|
||||||
onLoad,
|
|
||||||
onFail,
|
|
||||||
riseOnHover,
|
|
||||||
retryTime,
|
|
||||||
}}/>
|
}}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user