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 [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
**Describe the feature/solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Live deployment**
|
||||
|
||||
@ -28,6 +28,7 @@ import { revalidatePath } from 'next/cache';
|
||||
import RecipeModal from '@/recipe/RecipeModal';
|
||||
import ThemeColors from '@/app/ThemeColors';
|
||||
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
||||
import OGTooltipProvider from '@/components/og/OGTooltipProvider';
|
||||
|
||||
import '../tailwind.css';
|
||||
|
||||
@ -95,43 +96,45 @@ export default function RootLayout({
|
||||
<ThemeColors />
|
||||
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
||||
<SwrConfigClient>
|
||||
<div className={clsx(
|
||||
'mx-3 mb-3',
|
||||
'lg:mx-6 lg:mb-6',
|
||||
)}>
|
||||
<Nav
|
||||
navTitle={NAV_TITLE}
|
||||
navCaption={NAV_CAPTION}
|
||||
/>
|
||||
<main>
|
||||
<ShareModals />
|
||||
<RecipeModal />
|
||||
<div className={clsx(
|
||||
'min-h-[16rem] sm:min-h-[30rem]',
|
||||
'mb-12',
|
||||
'space-y-5',
|
||||
)}>
|
||||
<AdminUploadPanel
|
||||
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||
onLastUpload={async () => {
|
||||
'use server';
|
||||
// Update upload count in admin nav
|
||||
revalidatePath('/admin', 'layout');
|
||||
}}
|
||||
/>
|
||||
<AdminBatchEditPanel
|
||||
onBatchActionComplete={async () => {
|
||||
'use server';
|
||||
// Update upload count in admin nav
|
||||
revalidatePath('/admin', 'layout');
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<CommandK />
|
||||
<OGTooltipProvider>
|
||||
<div className={clsx(
|
||||
'mx-3 mb-3',
|
||||
'lg:mx-6 lg:mb-6',
|
||||
)}>
|
||||
<Nav
|
||||
navTitle={NAV_TITLE}
|
||||
navCaption={NAV_CAPTION}
|
||||
/>
|
||||
<main>
|
||||
<ShareModals />
|
||||
<RecipeModal />
|
||||
<div className={clsx(
|
||||
'min-h-[16rem] sm:min-h-[30rem]',
|
||||
'mb-12',
|
||||
'space-y-5',
|
||||
)}>
|
||||
<AdminUploadPanel
|
||||
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||
onLastUpload={async () => {
|
||||
'use server';
|
||||
// Update upload count in admin nav
|
||||
revalidatePath('/admin', 'layout');
|
||||
}}
|
||||
/>
|
||||
<AdminBatchEditPanel
|
||||
onBatchActionComplete={async () => {
|
||||
'use server';
|
||||
// Update upload count in admin nav
|
||||
revalidatePath('/admin', 'layout');
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<CommandK />
|
||||
</OGTooltipProvider>
|
||||
</SwrConfigClient>
|
||||
<Analytics debug={false} />
|
||||
<SpeedInsights debug={false} />
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { Camera } from '.';
|
||||
import { descriptionForCameraPhotos, titleForCamera } from './meta';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
@ -10,42 +10,30 @@ import { useAppText } from '@/i18n/state/client';
|
||||
export default function CameraOGTile({
|
||||
camera,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
camera: Camera
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForCamera(camera, photos, appText, count),
|
||||
description: descriptionForCameraPhotos(
|
||||
photos,
|
||||
appText,
|
||||
true,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
description:
|
||||
descriptionForCameraPhotos(
|
||||
photos,
|
||||
appText,
|
||||
true,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
path: pathForCamera(camera),
|
||||
pathImage: pathForCameraImage(camera),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,49 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, 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';
|
||||
type LoadingState = 'loading' | 'loaded' | 'failed';
|
||||
|
||||
export default function OGLoaderImage({
|
||||
title,
|
||||
path,
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
className,
|
||||
enabled = true,
|
||||
}: {
|
||||
title: string
|
||||
path: string
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
retryTime?: number
|
||||
className?: string
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
|
||||
const [loadingStateInternal, setLoadingStateInternal] =
|
||||
useState(loadingStateExternal ?? 'unloaded');
|
||||
|
||||
const loadingState = loadingStateExternal ?? loadingStateInternal;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loadingStateExternal &&
|
||||
loadingStateInternal === 'unloaded'
|
||||
) {
|
||||
setLoadingStateInternal('loading');
|
||||
}
|
||||
}, [loadingStateExternal, loadingStateInternal]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('loading');
|
||||
|
||||
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current?.complete) {
|
||||
setLoadingState('loading');
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
className={clsx(
|
||||
'relative',
|
||||
className,
|
||||
@ -67,6 +59,7 @@ export default function OGLoaderImage({
|
||||
</div>}
|
||||
{(loadingState === 'loading' || loadingState === 'loaded') &&
|
||||
<img
|
||||
ref={ref}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
'absolute top-0 left-0 right-0 bottom-0 z-0',
|
||||
@ -74,25 +67,18 @@ export default function OGLoaderImage({
|
||||
loadingState === 'loading' && 'opacity-0',
|
||||
'transition-opacity',
|
||||
)}
|
||||
src={path}
|
||||
src={enabled ? path : ''}
|
||||
width={width}
|
||||
height={height}
|
||||
onLoad={() => {
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
} else {
|
||||
setLoadingStateInternal('loaded');
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
if (onFail) {
|
||||
onFail();
|
||||
} else {
|
||||
setLoadingStateInternal('failed');
|
||||
}
|
||||
onLoadStart={() => setLoadingState('loading')}
|
||||
onLoad={() => setLoadingState('loaded')}
|
||||
onError={e => {
|
||||
setLoadingState('failed');
|
||||
if (retryTime !== undefined) {
|
||||
setLoadingState('loading');
|
||||
setTimeout(() => {
|
||||
setLoadingStateInternal('loading');
|
||||
e.currentTarget.src = '';
|
||||
e.currentTarget.src = path;
|
||||
}, retryTime);
|
||||
}
|
||||
}}
|
||||
|
||||
@ -6,7 +6,10 @@ import Link from 'next/link';
|
||||
import useVisible from '@/utility/useVisible';
|
||||
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({
|
||||
path,
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import TooltipPrimitive from '../primitives/TooltipPrimitive';
|
||||
import { ComponentProps, ReactNode, useRef, useEffect } from 'react';
|
||||
import OGLoaderImage from './OGLoaderImage';
|
||||
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
||||
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({
|
||||
children,
|
||||
@ -12,35 +20,53 @@ export default function OGTooltip({
|
||||
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-extra-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>}
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { showTooltip, dismissTooltip } = useOGTooltipState();
|
||||
|
||||
const supportsHover = useSupportsHover();
|
||||
|
||||
useEffect(() => {
|
||||
const trigger = ref.current;
|
||||
return () => dismissTooltip?.(trigger);
|
||||
}, [dismissTooltip]);
|
||||
|
||||
const content =
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<OGLoaderImage
|
||||
{...props}
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-[0.25rem]',
|
||||
'outline-medium bg-extra-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>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-full"
|
||||
ref={ref}
|
||||
onMouseEnter={() => supportsHover &&
|
||||
showTooltip?.(
|
||||
ref.current,
|
||||
{ content, width, height, offsetAbove, offsetBelow },
|
||||
)}
|
||||
onMouseLeave={() => supportsHover &&
|
||||
dismissTooltip?.(ref.current)}
|
||||
>
|
||||
{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,
|
||||
pathForFilmImage,
|
||||
} from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { descriptionForFilmPhotos, titleForFilm } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function FilmOGTile({
|
||||
film,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
film: string
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForFilm(film, photos, appText, count),
|
||||
description:
|
||||
descriptionForFilmPhotos(photos, appText, true, count, dateRange),
|
||||
path: pathForFilm(film),
|
||||
pathImage: pathForFilmImage(film),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,49 +5,37 @@ import {
|
||||
pathForFocalLength,
|
||||
pathForFocalLengthImage,
|
||||
} from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function FocalLengthOGTile({
|
||||
focal,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
focal: number
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForFocalLength(focal, photos, appText, count),
|
||||
description: descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
appText,
|
||||
true,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
description:
|
||||
descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
appText,
|
||||
true,
|
||||
count,
|
||||
dateRange,
|
||||
),
|
||||
path: pathForFocalLength(focal),
|
||||
pathImage: pathForFocalLengthImage(focal),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,117 +1,118 @@
|
||||
export { bn as default } from 'date-fns/locale/bn';
|
||||
|
||||
export const TEXT = {
|
||||
photo: {
|
||||
photo: 'ছবি',
|
||||
photoPlural: 'ছবিগুলো',
|
||||
taken: 'তোলা হয়েছে',
|
||||
created: 'তৈরি হয়েছে',
|
||||
updated: 'আপডেট হয়েছে',
|
||||
copied: 'ছবির লিংক কপি হয়েছে',
|
||||
},
|
||||
category: {
|
||||
camera: 'ক্যামেরা',
|
||||
cameraPlural: 'ক্যামেরাসমূহ',
|
||||
cameraTitle: '{{camera}} দিয়ে তোলা',
|
||||
cameraShare: '{{camera}} দিয়ে তোলা ছবিগুলো',
|
||||
lens: 'লেন্স',
|
||||
lensPlural: 'লেন্সগুলো',
|
||||
tag: 'ট্যাগ',
|
||||
tagPlural: 'ট্যাগসমূহ',
|
||||
taggedPhotos: 'ট্যাগকৃত ছবি',
|
||||
taggedPhrase: '{{tag}} ট্যাগ দেওয়া ছবি',
|
||||
taggedFavs: 'পছন্দের ছবি',
|
||||
recipe: 'রেসিপি',
|
||||
recipePlural: 'রেসিপিসমূহ',
|
||||
recipeShare: '{{recipe}} রেসিপি ছবিগুলো',
|
||||
film: 'ফিল্ম',
|
||||
filmPlural: 'ফিল্মসমূহ',
|
||||
filmShare: '{{film}} দিয়ে তোলা ছবিগুলো',
|
||||
focalLength: 'ফোকাল দৈর্ঘ্য',
|
||||
focalLengthPlural: 'ফোকাল দৈর্ঘ্যগুলো',
|
||||
focalLengthTitle: '{{focal}} ফোকাল দৈর্ঘ্য',
|
||||
focalLengthShare: '{{focal}} এ তোলা ছবিগুলো',
|
||||
},
|
||||
nav: {
|
||||
home: 'হোম',
|
||||
feed: 'ফিড',
|
||||
grid: 'গ্রিড',
|
||||
admin: 'অ্যাডমিন',
|
||||
search: 'সার্চ',
|
||||
prev: 'পূর্ববর্তী',
|
||||
prevShort: 'পূর্ব',
|
||||
next: 'পরবর্তী',
|
||||
nextShort: 'পরবর্তী',
|
||||
},
|
||||
cmdk: {
|
||||
placeholder: 'ছবি, ভিউ, সেটিংস অনুসন্ধান করুন ...',
|
||||
searching: 'অনুসন্ধান হচ্ছে ...',
|
||||
noResults: 'কোনো ফলাফল পাওয়া যায়নি',
|
||||
},
|
||||
tooltip: {
|
||||
'35mm': '৩৫মিমি সমতুল্য',
|
||||
zoom: 'জুম ইন',
|
||||
sharePhoto: 'ছবি শেয়ার করুন',
|
||||
recipeInfo: 'রেসিপি তথ্য',
|
||||
recipeCopy: 'রেসিপি কপি করুন',
|
||||
download: 'মূল ফাইল ডাউনলোড করুন',
|
||||
},
|
||||
theme: {
|
||||
theme: 'থিম',
|
||||
system: 'সিস্টেম',
|
||||
light: 'লাইট মোড',
|
||||
dark: 'ডার্ক মোড',
|
||||
},
|
||||
auth: {
|
||||
signIn: 'সাইন ইন',
|
||||
signOut: 'সাইন আউট',
|
||||
email: 'অ্যাডমিন ইমেইল',
|
||||
password: 'অ্যাডমিন পাসওয়ার্ড',
|
||||
invalidEmailPassword: 'ইমেইল বা পাসওয়ার্ড ভুল',
|
||||
},
|
||||
admin: {
|
||||
uploadPhotos: 'ছবি আপলোড করুন',
|
||||
upload: 'আপলোড',
|
||||
uploadPlural: 'আপলোডসমূহ',
|
||||
uploading: 'আপলোড হচ্ছে',
|
||||
update: 'আপডেট',
|
||||
updatePlural: 'আপডেটসমূহ',
|
||||
managePhotos: 'ছবি ব্যবস্থাপনা করুন',
|
||||
manageCameras: 'ক্যামেরা ব্যবস্থাপনা করুন',
|
||||
manageLenses: 'লেন্স ব্যবস্থাপনা করুন',
|
||||
manageTags: 'ট্যাগ ব্যবস্থাপনা করুন',
|
||||
manageRecipes: 'রেসিপি ব্যবস্থাপনা করুন',
|
||||
batchEdit: 'একসাথে ছবিগুলো এডিট করুন ...',
|
||||
batchEditShort: 'ব্যাচ এডিট ...',
|
||||
batchExitEdit: 'ব্যাচ এডিট থেকে বের হোন',
|
||||
appInsights: 'অ্যাপ ইনসাইট',
|
||||
appConfig: 'অ্যাপ কনফিগারেশন',
|
||||
edit: 'এডিট',
|
||||
favorite: 'পছন্দ',
|
||||
unfavorite: 'পছন্দ অপসারণ',
|
||||
hide: 'লুকান',
|
||||
unhide: 'দেখান',
|
||||
download: 'ডাউনলোড',
|
||||
sync: 'সিঙ্ক',
|
||||
delete: 'ডিলিট',
|
||||
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
||||
},
|
||||
onboarding: {
|
||||
setupComplete: 'সেটআপ সম্পন্ন!',
|
||||
setupIncomplete: 'সেটআপ সম্পূর্ণ করুন',
|
||||
setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন',
|
||||
setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন',
|
||||
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
|
||||
},
|
||||
misc: {
|
||||
loading: 'লোড হচ্ছে ...',
|
||||
finishing: 'সম্পন্ন হচ্ছে ...',
|
||||
uploading: 'আপলোড হচ্ছে',
|
||||
repo: 'তৈরি হয়েছে',
|
||||
copyPhrase: '{{label}} কপি হয়েছে',
|
||||
},
|
||||
utility: {
|
||||
paginate: '{{index}} / {{count}}',
|
||||
paginateAction: '{{action}} - {{index}} / {{count}}',
|
||||
},
|
||||
};
|
||||
export { bn as default } from 'date-fns/locale/bn';
|
||||
|
||||
export const TEXT = {
|
||||
photo: {
|
||||
photo: 'ছবি',
|
||||
photoPlural: 'ছবিগুলো',
|
||||
taken: 'তোলা হয়েছে',
|
||||
created: 'তৈরি হয়েছে',
|
||||
updated: 'আপডেট হয়েছে',
|
||||
copied: 'ছবির লিংক কপি হয়েছে',
|
||||
},
|
||||
category: {
|
||||
camera: 'ক্যামেরা',
|
||||
cameraPlural: 'ক্যামেরাসমূহ',
|
||||
cameraTitle: '{{camera}} দিয়ে তোলা',
|
||||
cameraShare: '{{camera}} দিয়ে তোলা ছবিগুলো',
|
||||
lens: 'লেন্স',
|
||||
lensPlural: 'লেন্সগুলো',
|
||||
tag: 'ট্যাগ',
|
||||
tagPlural: 'ট্যাগসমূহ',
|
||||
taggedPhotos: 'ট্যাগকৃত ছবি',
|
||||
taggedPhrase: '{{tag}} ট্যাগ দেওয়া ছবি',
|
||||
taggedFavs: 'পছন্দের ছবি',
|
||||
recipe: 'রেসিপি',
|
||||
recipePlural: 'রেসিপিসমূহ',
|
||||
recipeShare: '{{recipe}} রেসিপি ছবিগুলো',
|
||||
film: 'ফিল্ম',
|
||||
filmPlural: 'ফিল্মসমূহ',
|
||||
filmShare: '{{film}} দিয়ে তোলা ছবিগুলো',
|
||||
focalLength: 'ফোকাল দৈর্ঘ্য',
|
||||
focalLengthPlural: 'ফোকাল দৈর্ঘ্যগুলো',
|
||||
focalLengthTitle: '{{focal}} ফোকাল দৈর্ঘ্য',
|
||||
focalLengthShare: '{{focal}} এ তোলা ছবিগুলো',
|
||||
},
|
||||
nav: {
|
||||
home: 'হোম',
|
||||
feed: 'ফিড',
|
||||
grid: 'গ্রিড',
|
||||
admin: 'অ্যাডমিন',
|
||||
search: 'সার্চ',
|
||||
prev: 'পূর্ববর্তী',
|
||||
prevShort: 'পূর্ব',
|
||||
next: 'পরবর্তী',
|
||||
nextShort: 'পরবর্তী',
|
||||
},
|
||||
cmdk: {
|
||||
placeholder: 'ছবি, ভিউ, সেটিংস অনুসন্ধান করুন ...',
|
||||
searching: 'অনুসন্ধান হচ্ছে ...',
|
||||
noResults: 'কোনো ফলাফল পাওয়া যায়নি',
|
||||
},
|
||||
tooltip: {
|
||||
'35mm': '৩৫মিমি সমতুল্য',
|
||||
zoom: 'জুম ইন',
|
||||
sharePhoto: 'ছবি শেয়ার করুন',
|
||||
recipeInfo: 'রেসিপি তথ্য',
|
||||
recipeCopy: 'রেসিপি কপি করুন',
|
||||
download: 'মূল ফাইল ডাউনলোড করুন',
|
||||
},
|
||||
theme: {
|
||||
theme: 'থিম',
|
||||
system: 'সিস্টেম',
|
||||
light: 'লাইট মোড',
|
||||
dark: 'ডার্ক মোড',
|
||||
},
|
||||
auth: {
|
||||
signIn: 'সাইন ইন',
|
||||
signOut: 'সাইন আউট',
|
||||
email: 'অ্যাডমিন ইমেইল',
|
||||
password: 'অ্যাডমিন পাসওয়ার্ড',
|
||||
invalidEmailPassword: 'ইমেইল বা পাসওয়ার্ড ভুল',
|
||||
},
|
||||
admin: {
|
||||
uploadPhotos: 'ছবি আপলোড করুন',
|
||||
upload: 'আপলোড',
|
||||
uploadPlural: 'আপলোডসমূহ',
|
||||
uploading: 'আপলোড হচ্ছে',
|
||||
update: 'আপডেট',
|
||||
updatePlural: 'আপডেটসমূহ',
|
||||
managePhotos: 'ছবি ব্যবস্থাপনা করুন',
|
||||
manageCameras: 'ক্যামেরা ব্যবস্থাপনা করুন',
|
||||
manageLenses: 'লেন্স ব্যবস্থাপনা করুন',
|
||||
manageTags: 'ট্যাগ ব্যবস্থাপনা করুন',
|
||||
manageRecipes: 'রেসিপি ব্যবস্থাপনা করুন',
|
||||
batchEdit: 'একসাথে ছবিগুলো এডিট করুন ...',
|
||||
batchEditShort: 'ব্যাচ এডিট ...',
|
||||
batchExitEdit: 'ব্যাচ এডিট থেকে বের হোন',
|
||||
appInsights: 'অ্যাপ ইনসাইট',
|
||||
appConfig: 'অ্যাপ কনফিগারেশন',
|
||||
edit: 'এডিট',
|
||||
favorite: 'পছন্দ',
|
||||
unfavorite: 'পছন্দ অপসারণ',
|
||||
hide: 'লুকান',
|
||||
unhide: 'দেখান',
|
||||
download: 'ডাউনলোড',
|
||||
sync: 'সিঙ্ক',
|
||||
delete: 'ডিলিট',
|
||||
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
||||
},
|
||||
onboarding: {
|
||||
setupComplete: 'সেটআপ সম্পন্ন!',
|
||||
setupIncomplete: 'সেটআপ সম্পূর্ণ করুন',
|
||||
setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন',
|
||||
setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন',
|
||||
// eslint-disable-next-line max-len
|
||||
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
|
||||
},
|
||||
misc: {
|
||||
loading: 'লোড হচ্ছে ...',
|
||||
finishing: 'সম্পন্ন হচ্ছে ...',
|
||||
uploading: 'আপলোড হচ্ছে',
|
||||
repo: 'তৈরি হয়েছে',
|
||||
copyPhrase: '{{label}} কপি হয়েছে',
|
||||
},
|
||||
utility: {
|
||||
paginate: '{{index}} / {{count}}',
|
||||
paginateAction: '{{action}} - {{index}} / {{count}}',
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
import { Lens } from '.';
|
||||
import { titleForLens, descriptionForLensPhotos } from './meta';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
@ -8,27 +8,19 @@ import { useAppText } from '@/i18n/state/client';
|
||||
export default function LensOGTile({
|
||||
lens,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
lens: Lens
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForLens(lens, photos, appText, count),
|
||||
description: descriptionForLensPhotos(
|
||||
photos,
|
||||
@ -39,11 +31,6 @@ export default function LensOGTile({
|
||||
),
|
||||
path: pathForLens(lens),
|
||||
pathImage: pathForLensImage(lens),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,35 +7,23 @@ import {
|
||||
} from '@/photo';
|
||||
import { PhotoSetCategory } from '../category';
|
||||
import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
|
||||
import OGTile, { OGLoadingState } from '@/components/og/OGTile';
|
||||
import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
|
||||
|
||||
export default function PhotoOGTile({
|
||||
photo,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
onVisible,
|
||||
...categories
|
||||
}: {
|
||||
photo: Photo
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
onVisible?: () => void
|
||||
} & PhotoSetCategory) {
|
||||
} & PhotoSetCategory & OGTilePropsCore) {
|
||||
return (
|
||||
<OGTile {...{
|
||||
title: titleForPhoto(photo),
|
||||
description: descriptionForPhoto(photo),
|
||||
path: pathForPhoto({ photo, ...categories }),
|
||||
pathImage: pathForPhotoImage(photo),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
onVisible,
|
||||
|
||||
@ -1,69 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Photo } from '@/photo';
|
||||
import PhotoOGTile from './PhotoOGTile';
|
||||
import { OGLoadingState } from '@/components/og/OGTile';
|
||||
|
||||
const DEFAULT_MAX_CONCURRENCY = 3;
|
||||
|
||||
type PhotoLoadingState = Record<string, OGLoadingState>;
|
||||
|
||||
export default function StaggeredOgPhotos({
|
||||
photos,
|
||||
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
|
||||
onLastPhotoVisible,
|
||||
}: {
|
||||
photos: Photo[]
|
||||
maxConcurrency?: number
|
||||
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 (
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{photos.map((photo, index) =>
|
||||
<PhotoOGTile
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
loadingState={loadingState[photo.id]}
|
||||
onLoad={() => recomputeLoadingState({ [photo.id]: 'loaded' })}
|
||||
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
|
||||
onVisible={index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
: undefined}
|
||||
|
||||
@ -1,33 +1,25 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
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 { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function RecipeOGTile({
|
||||
recipe,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
recipe: string
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForRecipe(recipe, photos, appText, count),
|
||||
description: descriptionForRecipePhotos(
|
||||
photos,
|
||||
@ -38,11 +30,6 @@ export default function RecipeOGTile({
|
||||
),
|
||||
path: pathForRecipe(recipe),
|
||||
pathImage: pathForRecipeImage(recipe),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,34 +2,26 @@
|
||||
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
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 { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function TagOGTile({
|
||||
tag,
|
||||
photos,
|
||||
loadingState: loadingStateExternal,
|
||||
riseOnHover,
|
||||
onLoad,
|
||||
onFail,
|
||||
retryTime,
|
||||
count,
|
||||
dateRange,
|
||||
...props
|
||||
}: {
|
||||
tag: string
|
||||
photos: Photo[]
|
||||
loadingState?: OGLoadingState
|
||||
onLoad?: () => void
|
||||
onFail?: () => void
|
||||
riseOnHover?: boolean
|
||||
retryTime?: number
|
||||
count?: number
|
||||
dateRange?: PhotoDateRange
|
||||
}) {
|
||||
} & OGTilePropsCore) {
|
||||
const appText = useAppText();
|
||||
return (
|
||||
<OGTile {...{
|
||||
...props,
|
||||
title: titleForTag(tag, photos, appText, count),
|
||||
description: descriptionForTaggedPhotos(
|
||||
photos,
|
||||
@ -40,11 +32,6 @@ export default function TagOGTile({
|
||||
),
|
||||
path: pathForTag(tag),
|
||||
pathImage: pathForTagImage(tag),
|
||||
loadingState: loadingStateExternal,
|
||||
onLoad,
|
||||
onFail,
|
||||
riseOnHover,
|
||||
retryTime,
|
||||
}}/>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user