From d2e62a90917de649e3bce10ba023bab3403376b4 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 22 Jun 2025 15:03:18 -0500 Subject: [PATCH] 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 --- .github/ISSUE_TEMPLATE/feature_request.md | 4 +- app/layout.tsx | 77 +++---- src/camera/CameraOGTile.tsx | 36 ++-- src/components/og/OGLoaderImage.tsx | 58 ++---- src/components/og/OGTile.tsx | 5 +- src/components/og/OGTooltip.tsx | 84 +++++--- src/components/og/OGTooltipProvider.tsx | 131 ++++++++++++ src/components/og/state.ts | 18 ++ src/film/FilmOGTile.tsx | 21 +- src/focal/FocalLengthOGTile.tsx | 36 ++-- src/i18n/locales/bd-bn.ts | 235 +++++++++++----------- src/lens/LensOGTile.tsx | 21 +- src/photo/PhotoOGTile.tsx | 16 +- src/photo/StaggeredOgPhotos.tsx | 47 ----- src/recipe/RecipeOGTile.tsx | 21 +- src/tag/TagOGTile.tsx | 21 +- 16 files changed, 432 insertions(+), 399 deletions(-) create mode 100644 src/components/og/OGTooltipProvider.tsx create mode 100644 src/components/og/state.ts diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3e684497..78dcf2d0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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** diff --git a/app/layout.tsx b/app/layout.tsx index 6dfcff78..e3e1fd25 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ -
-
- + +
+
+ +
diff --git a/src/camera/CameraOGTile.tsx b/src/camera/CameraOGTile.tsx index eeac6d78..b4a2d0f5 100644 --- a/src/camera/CameraOGTile.tsx +++ b/src/camera/CameraOGTile.tsx @@ -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 ( ); }; diff --git a/src/components/og/OGLoaderImage.tsx b/src/components/og/OGLoaderImage.tsx index b5cbe272..9412325b 100644 --- a/src/components/og/OGLoaderImage.tsx +++ b/src/components/og/OGLoaderImage.tsx @@ -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(null); - const [loadingStateInternal, setLoadingStateInternal] = - useState(loadingStateExternal ?? 'unloaded'); - - const loadingState = loadingStateExternal ?? loadingStateInternal; - - useEffect(() => { - if ( - !loadingStateExternal && - loadingStateInternal === 'unloaded' - ) { - setLoadingStateInternal('loading'); - } - }, [loadingStateExternal, loadingStateInternal]); + const [loadingState, setLoadingState] = useState('loading'); const { width, height, aspectRatio } = IMAGE_OG_DIMENSION; + useEffect(() => { + if (!ref.current?.complete) { + setLoadingState('loading'); + } + }, [path]); + return (
} {(loadingState === 'loading' || loadingState === 'loaded') && {title} { - 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); } }} diff --git a/src/components/og/OGTile.tsx b/src/components/og/OGTile.tsx index 47c33554..dd2564f3 100644 --- a/src/components/og/OGTile.tsx +++ b/src/components/og/OGTile.tsx @@ -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, + 'title' | 'description' | 'path' | 'pathImage' +>; export default function OGTile({ path, diff --git a/src/components/og/OGTooltip.tsx b/src/components/og/OGTooltip.tsx index 017a87f2..74a35e7b 100644 --- a/src/components/og/OGTooltip.tsx +++ b/src/components/og/OGTooltip.tsx @@ -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) { - const { aspectRatio } = IMAGE_OG_DIMENSION; - return ( - - - {caption &&
- {caption} -
} + const ref = useRef(null); + + const { showTooltip, dismissTooltip } = useOGTooltipState(); + + const supportsHover = useSupportsHover(); + + useEffect(() => { + const trigger = ref.current; + return () => dismissTooltip?.(trigger); + }, [dismissTooltip]); + + const content = +
+ + {caption &&
+ {caption}
} +
; + + return ( +
supportsHover && + showTooltip?.( + ref.current, + { content, width, height, offsetAbove, offsetBelow }, + )} + onMouseLeave={() => supportsHover && + dismissTooltip?.(ref.current)} > {children} - +
); } diff --git a/src/components/og/OGTooltipProvider.tsx b/src/components/og/OGTooltipProvider.tsx new file mode 100644 index 00000000..e7bd2b19 --- /dev/null +++ b/src/components/og/OGTooltipProvider.tsx @@ -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(); + const [tooltipStyle, setTooltipStyle] = useState(); + + const currentTriggerRef = useRef(null); + + const timeoutInitialHoverRef = useRef(undefined); + const timeoutDismissRef = useRef(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 ( + +
+ + {currentTooltip && + + + {currentTooltip.content} + + } + +
+ {children} +
+ ); +} diff --git a/src/components/og/state.ts b/src/components/og/state.ts new file mode 100644 index 00000000..08b2a5c8 --- /dev/null +++ b/src/components/og/state.ts @@ -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({}); + +export const useOGTooltipState = () => use(OGTooltipContext); diff --git a/src/film/FilmOGTile.tsx b/src/film/FilmOGTile.tsx index 238205c4..ddd74f2d 100644 --- a/src/film/FilmOGTile.tsx +++ b/src/film/FilmOGTile.tsx @@ -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 ( ); }; diff --git a/src/focal/FocalLengthOGTile.tsx b/src/focal/FocalLengthOGTile.tsx index d1b81cff..ee552b56 100644 --- a/src/focal/FocalLengthOGTile.tsx +++ b/src/focal/FocalLengthOGTile.tsx @@ -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 ( ); }; diff --git a/src/i18n/locales/bd-bn.ts b/src/i18n/locales/bd-bn.ts index 98e0caa3..5bed115a 100644 --- a/src/i18n/locales/bd-bn.ts +++ b/src/i18n/locales/bd-bn.ts @@ -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}}', + }, +}; diff --git a/src/lens/LensOGTile.tsx b/src/lens/LensOGTile.tsx index 79bd9948..a43c5b0d 100644 --- a/src/lens/LensOGTile.tsx +++ b/src/lens/LensOGTile.tsx @@ -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 ( ); }; diff --git a/src/photo/PhotoOGTile.tsx b/src/photo/PhotoOGTile.tsx index a2cc70d5..628decd5 100644 --- a/src/photo/PhotoOGTile.tsx +++ b/src/photo/PhotoOGTile.tsx @@ -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 ( ; 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 (
{photos.map((photo, index) => recomputeLoadingState({ [photo.id]: 'loaded' })} - onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })} onVisible={index === photos.length - 1 ? onLastPhotoVisible : undefined} diff --git a/src/recipe/RecipeOGTile.tsx b/src/recipe/RecipeOGTile.tsx index b2d81e03..9977d64a 100644 --- a/src/recipe/RecipeOGTile.tsx +++ b/src/recipe/RecipeOGTile.tsx @@ -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 ( ); }; diff --git a/src/tag/TagOGTile.tsx b/src/tag/TagOGTile.tsx index d6a34cdb..340e677c 100644 --- a/src/tag/TagOGTile.tsx +++ b/src/tag/TagOGTile.tsx @@ -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 ( ); };