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:
Sam Becker 2025-06-22 15:03:18 -05:00 committed by GitHub
parent 9a3b60bcd9
commit d2e62a9091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 432 additions and 399 deletions

View File

@ -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**

View File

@ -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} />

View File

@ -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,
}}/>
);
};

View File

@ -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);
}
}}

View File

@ -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,

View File

@ -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>
);
}

View 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>
);
}

View 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);

View File

@ -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,
}}/>
);
};

View File

@ -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,
}}/>
);
};

View File

@ -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}}',
},
};

View File

@ -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,
}}/>
);
};

View File

@ -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,

View File

@ -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}

View File

@ -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,
}}/>
);
};

View File

@ -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,
}}/>
);
};