Speed up category hovers (#279)
* Extract out ShareHover components * Refactor hover/category state * Rename photo query options types * Restore category count slice of app state * Streamline entity hover headers * Standardize swr keys * Suppress hover counts to years * Refine entity hover design * Make image hovers opt out
This commit is contained in:
parent
a59af8a505
commit
b7cb6715b7
@ -140,6 +140,7 @@ Application behavior can be changed by configuring the following environment var
|
||||
- `recipes` (default)
|
||||
- `films` (default)
|
||||
- `focal-lengths`
|
||||
- `NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS = 1` prevents images displaying when hovering over category links:
|
||||
- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
|
||||
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
|
||||
|
||||
@ -157,7 +158,6 @@ Application behavior can be changed by configuring the following environment var
|
||||
#### Display
|
||||
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
|
||||
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
|
||||
- `NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS = 1` shows images when hovering over category links like cameras and lenses (⚠️ setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES = 1` strongly recommended for responsive hover interactions)
|
||||
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
|
||||
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
|
||||
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
|
||||
|
||||
@ -5,10 +5,10 @@ import Badge from '@/components/Badge';
|
||||
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import EntityLink from '@/components/primitives/EntityLink';
|
||||
import EntityLink from '@/components/entity/EntityLink';
|
||||
import LabeledIcon from '@/components/primitives/LabeledIcon';
|
||||
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';
|
||||
|
||||
@ -10,11 +10,11 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/db/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||
import { GetPhotosOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||
}));
|
||||
|
||||
@ -9,12 +9,12 @@ import { getPhotos } from '@/photo/db/query';
|
||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { GetPhotosOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||
}));
|
||||
|
||||
@ -11,11 +11,11 @@ import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { SortProps } from '@/photo/db/sort';
|
||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||
import { GetPhotosOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||
}));
|
||||
|
||||
@ -10,12 +10,12 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
||||
import { GetPhotosOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||
}));
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
SITE_FEEDS_ENABLED,
|
||||
ADMIN_DEBUG_TOOLS_ENABLED,
|
||||
} from '@/app/config';
|
||||
import AppStateProvider from '@/state/AppStateProvider';
|
||||
import AppStateProvider from '@/app/AppStateProvider';
|
||||
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
||||
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
||||
import { Metadata } from 'next/types';
|
||||
@ -21,7 +21,7 @@ import { ThemeProvider } from 'next-themes';
|
||||
import Nav from '@/app/Nav';
|
||||
import Footer from '@/app/Footer';
|
||||
import CommandK from '@/cmdk/CommandK';
|
||||
import SwrConfigClient from '@/state/SwrConfigClient';
|
||||
import SwrConfigClient from '@/swr/SwrConfigClient';
|
||||
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
|
||||
import ShareModals from '@/share/ShareModals';
|
||||
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
|
||||
@ -29,7 +29,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 SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
|
||||
|
||||
import '../tailwind.css';
|
||||
|
||||
@ -97,7 +97,7 @@ export default function RootLayout({
|
||||
<ThemeColors />
|
||||
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
||||
<SwrConfigClient>
|
||||
<OGTooltipProvider>
|
||||
<SharedHoverProvider>
|
||||
<div className={clsx(
|
||||
'mx-3 mb-3',
|
||||
'lg:mx-6 lg:mb-6',
|
||||
@ -135,7 +135,7 @@ export default function RootLayout({
|
||||
<Footer />
|
||||
</div>
|
||||
<CommandK />
|
||||
</OGTooltipProvider>
|
||||
</SharedHoverProvider>
|
||||
</SwrConfigClient>
|
||||
<Analytics debug={false} />
|
||||
<SpeedInsights debug={false} />
|
||||
|
||||
@ -13,12 +13,12 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||
import { getDataForCategoriesCached } from '@/category/cache';
|
||||
import { getPhotosMetaCached } from '@/photo/cache';
|
||||
import { GetPhotosOptions } from '@/photo/db';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
||||
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||
...options,
|
||||
limit: GRID_HOMEPAGE_ENABLED
|
||||
? INFINITE_SCROLL_GRID_INITIAL
|
||||
|
||||
22
package.json
22
package.json
@ -10,8 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@aws-sdk/client-s3": "3.840.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.840.0",
|
||||
"@aws-sdk/client-s3": "3.842.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.842.0",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
@ -28,9 +28,9 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"framer-motion": "^12.22.0",
|
||||
"framer-motion": "^12.23.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "15.3.4",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "5.0.0-beta.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
@ -39,16 +39,16 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "^0.34.2",
|
||||
"sonner": "^2.0.5",
|
||||
"swr": "^2.3.3",
|
||||
"sonner": "^2.0.6",
|
||||
"swr": "^2.3.4",
|
||||
"ts-exif-parser": "^0.2.2",
|
||||
"use-debounce": "^10.0.5",
|
||||
"viewerjs": "^1.11.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@next/bundle-analyzer": "15.3.4",
|
||||
"@next/eslint-plugin-next": "^15.3.4",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "^15.3.5",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
@ -63,10 +63,10 @@
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jest": "^30.0.3",
|
||||
"jest-environment-jsdom": "^30.0.2",
|
||||
"jest": "^30.0.4",
|
||||
"jest-environment-jsdom": "^30.0.4",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "4.1.11",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
494
pnpm-lock.yaml
generated
494
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -82,8 +82,9 @@ export default function AdminAppConfigurationClient({
|
||||
imageQuality,
|
||||
isBlurEnabled,
|
||||
// Categories
|
||||
categoryVisibility,
|
||||
hasCategoryVisibility,
|
||||
categoryVisibility,
|
||||
showCategoryImageHover,
|
||||
collapseSidebarCategories,
|
||||
hideTagsWithOnePhoto,
|
||||
// Sort
|
||||
@ -94,7 +95,6 @@ export default function AdminAppConfigurationClient({
|
||||
// Display
|
||||
showKeyboardShortcutTooltips,
|
||||
showExifInfo,
|
||||
showCategoryImageHover,
|
||||
showZoomControls,
|
||||
showTakenAtTimeHidden,
|
||||
showSocial,
|
||||
@ -582,6 +582,19 @@ export default function AdminAppConfigurationClient({
|
||||
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
|
||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show image hovers"
|
||||
status={showCategoryImageHover}
|
||||
optional
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
Set environment variable to {'"1"'} to prevent images
|
||||
displaying when hovering over category links:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
|
||||
</div>
|
||||
</div>
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Collapsible sidebar"
|
||||
status={collapseSidebarCategories}
|
||||
@ -667,25 +680,6 @@ export default function AdminAppConfigurationClient({
|
||||
Set environment variable to {'"1"'} to hide EXIF data:
|
||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show category image hovers"
|
||||
status={showCategoryImageHover}
|
||||
optional
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
Set environment variable to {'"1"'} to show images when hovering
|
||||
over category links like cameras and lenses:
|
||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])}
|
||||
</div>
|
||||
<div>
|
||||
Static optimization strongly recommended
|
||||
for responsive hover interactions:
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
{renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])}
|
||||
</div>
|
||||
</div>
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Show zoom controls"
|
||||
status={showZoomControls}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import clsx from 'clsx/lite';
|
||||
import { LuCog } from 'react-icons/lu';
|
||||
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
PATH_ADMIN_UPLOADS,
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/paths';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import AdminAppInfoIcon from './AdminAppInfoIcon';
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import Note from '@/components/Note';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
@ -19,7 +19,7 @@ import ProgressButton from '@/components/primitives/ProgressButton';
|
||||
import { UrlAddStatus } from './AdminUploadsClient';
|
||||
import PhotoTagFieldset from './PhotoTagFieldset';
|
||||
import DeleteUploadButton from './DeleteUploadButton';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { pluralize } from '@/utility/string';
|
||||
import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
||||
import FieldsetHidden from '@/photo/form/FieldsetHidden';
|
||||
|
||||
@ -5,7 +5,7 @@ import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||
import clsx from 'clsx/lite';
|
||||
import ClearCacheButton from '@/admin/ClearCacheButton';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
||||
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
isPathAdminInfo,
|
||||
isPathTopLevelAdmin,
|
||||
} from '@/app/paths';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
@ -22,7 +22,7 @@ import { isPathFavs, isPhotoFav, TAG_HIDDEN } from '@/tag';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
import MoreMenu from '@/components/more/MoreMenu';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
import { MdOutlineFileDownload } from 'react-icons/md';
|
||||
import MoreMenuItem from '@/components/more/MoreMenuItem';
|
||||
|
||||
@ -10,7 +10,7 @@ import { Photo } from '@/photo';
|
||||
import { StorageListResponse } from '@/platforms/storage';
|
||||
import AdminUploadsTable from './AdminUploadsTable';
|
||||
import { Timezone } from '@/utility/timezone';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||
import { pluralize } from '@/utility/string';
|
||||
import IconBroom from '@/components/icons/IconBroom';
|
||||
|
||||
@ -9,7 +9,7 @@ import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
||||
import Link from 'next/link';
|
||||
import PhotoDate from '@/photo/PhotoDate';
|
||||
import EditButton from './EditButton';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
import PhotoSyncButton from './PhotoSyncButton';
|
||||
import DeletePhotoButton from './DeletePhotoButton';
|
||||
|
||||
@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
|
||||
export default function AdminRecipeForm({
|
||||
recipe,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { RecipeProps } from '@/recipe';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { TbChecklist } from 'react-icons/tb';
|
||||
|
||||
export default function AdminShowRecipeButton(props: RecipeProps) {
|
||||
|
||||
@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { renamePhotoTagGloballyAction } from '@/photo/actions';
|
||||
import { parameterize } from '@/utility/string';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
|
||||
export default function AdminTagForm({
|
||||
tag,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { clearCacheAction } from '@/photo/actions';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
|
||||
export default function ClearCacheButton() {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { ComponentProps, useCallback } from 'react';
|
||||
import { BiTrash } from 'react-icons/bi';
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import { deletePhotosAction } from '@/photo/actions';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { toastSuccess, toastWarning } from '@/toast';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
import DeleteButton from './DeleteButton';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import SignInForm from '@/auth/SignInForm';
|
||||
import clsx from 'clsx/lite';
|
||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||
|
||||
@ -36,7 +36,7 @@ import AdminLink from '../AdminLink';
|
||||
import AdminEmptyState from '../AdminEmptyState';
|
||||
import { pluralize } from '@/utility/string';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import ScoreCardContainer from '@/components/ScoreCardContainer';
|
||||
import IconLens from '@/components/icons/IconLens';
|
||||
import IconCamera from '@/components/icons/IconCamera';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import clsx from 'clsx/lite';
|
||||
import { FaCircle } from 'react-icons/fa6';
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import Container from '@/components/Container';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import AppGrid from '@/components/AppGrid';
|
||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import clsx from 'clsx/lite';
|
||||
import { IoCloseSharp } from 'react-icons/io5';
|
||||
|
||||
|
||||
@ -20,7 +20,6 @@ export type AppStateContextType = {
|
||||
previousPathname?: string
|
||||
hasLoaded?: boolean
|
||||
hasLoadedWithAnimations?: boolean
|
||||
swrTimestamp?: number
|
||||
invalidateSwr?: () => void
|
||||
nextPhotoAnimation?: AnimationConfig
|
||||
setNextPhotoAnimation?: (animationConfig?: AnimationConfig) => void
|
||||
@ -7,11 +7,11 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { AppStateContext } from './AppState';
|
||||
import { AppStateContext } from '../app/AppState';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import usePathnames from '@/utility/usePathnames';
|
||||
import { getAuthAction } from '@/auth/actions';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import {
|
||||
HIGH_DENSITY_GRID,
|
||||
IS_DEVELOPMENT,
|
||||
@ -30,9 +30,16 @@ import { useRouter, usePathname } from 'next/navigation';
|
||||
import { isPathProtected, PATH_ROOT } from '@/app/paths';
|
||||
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
|
||||
import { RecipeProps } from '@/recipe';
|
||||
import { getCountsForCategoriesCachedAction } from '@/category/actions';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { toastSuccess } from '@/toast';
|
||||
import { getCountsForCategoriesCachedAction } from '@/category/actions';
|
||||
import {
|
||||
canKeyBePurged,
|
||||
canKeyBePurgedAndRevalidated,
|
||||
SWR_KEY_GET_ADMIN_DATA,
|
||||
SWR_KEY_GET_AUTH,
|
||||
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
|
||||
} from '@/swr';
|
||||
|
||||
export default function AppStateProvider({
|
||||
children,
|
||||
@ -52,8 +59,6 @@ export default function AppStateProvider({
|
||||
useState(false);
|
||||
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
|
||||
useState(false);
|
||||
const [swrTimestamp, setSwrTimestamp] =
|
||||
useState(Date.now());
|
||||
const [nextPhotoAnimation, _setNextPhotoAnimation] =
|
||||
useState<AnimationConfig>();
|
||||
const setNextPhotoAnimation = useCallback((animation?: AnimationConfig) => {
|
||||
@ -123,10 +128,14 @@ export default function AppStateProvider({
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
|
||||
const { mutate } = useSWRConfig();
|
||||
const invalidateSwr = useCallback(() => {
|
||||
mutate(canKeyBePurged, undefined, { revalidate: false });
|
||||
mutate(canKeyBePurgedAndRevalidated, undefined, { revalidate: true });
|
||||
}, [mutate]);
|
||||
|
||||
const { data: categoriesWithCounts } = useSWR(
|
||||
'getDataForCategories',
|
||||
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
|
||||
getCountsForCategoriesCachedAction,
|
||||
);
|
||||
|
||||
@ -134,7 +143,7 @@ export default function AppStateProvider({
|
||||
data: auth,
|
||||
error: authError,
|
||||
isLoading: isCheckingAuth,
|
||||
} = useSWR('getAuth', getAuthAction);
|
||||
} = useSWR(SWR_KEY_GET_AUTH, getAuthAction);
|
||||
useEffect(() => {
|
||||
if (auth === null || authError) {
|
||||
setUserEmail(undefined);
|
||||
@ -152,7 +161,7 @@ export default function AppStateProvider({
|
||||
mutate: refreshAdminData,
|
||||
isLoading: isLoadingAdminData,
|
||||
} = useSWR(
|
||||
isUserSignedIn ? 'getAdminData' : null,
|
||||
isUserSignedIn ? SWR_KEY_GET_ADMIN_DATA : null,
|
||||
getAdminDataAction,
|
||||
);
|
||||
const updateAdminData = useCallback(
|
||||
@ -212,7 +221,6 @@ export default function AppStateProvider({
|
||||
previousPathname,
|
||||
hasLoaded,
|
||||
hasLoadedWithAnimations,
|
||||
swrTimestamp,
|
||||
invalidateSwr,
|
||||
nextPhotoAnimation,
|
||||
setNextPhotoAnimation,
|
||||
@ -8,7 +8,7 @@ import {
|
||||
PATH_GRID_INFERRED,
|
||||
} from '@/app/paths';
|
||||
import IconSearch from '../components/icons/IconSearch';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import {
|
||||
GRID_HOMEPAGE_ENABLED,
|
||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
|
||||
@ -11,7 +11,7 @@ import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
|
||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||
import { signOutAction } from '@/auth/actions';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
} from './config';
|
||||
import { useRef } from 'react';
|
||||
import useStickyNav from './useStickyNav';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
|
||||
const NAV_HEIGHT_CLASS = NAV_CAPTION
|
||||
? 'min-h-[4rem] sm:min-h-[5rem]'
|
||||
|
||||
@ -263,6 +263,8 @@ export const SHOW_FILMS =
|
||||
CATEGORY_VISIBILITY.includes('films');
|
||||
export const SHOW_FOCAL_LENGTHS =
|
||||
CATEGORY_VISIBILITY.includes('focal-lengths');
|
||||
export const SHOW_CATEGORY_IMAGE_HOVERS =
|
||||
process.env.NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS !== '1';
|
||||
export const COLLAPSE_SIDEBAR_CATEGORIES =
|
||||
process.env.NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES !== '1';
|
||||
export const HIDE_TAGS_WITH_ONE_PHOTO =
|
||||
@ -287,8 +289,6 @@ export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
|
||||
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||
export const SHOW_EXIF_DATA =
|
||||
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
|
||||
export const SHOW_CATEGORY_IMAGE_HOVERS =
|
||||
process.env.NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS === '1';
|
||||
export const SHOW_ZOOM_CONTROLS =
|
||||
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
||||
export const SHOW_TAKEN_AT_TIME =
|
||||
@ -415,6 +415,7 @@ export const APP_CONFIGURATION = {
|
||||
hasCategoryVisibility:
|
||||
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
|
||||
categoryVisibility: CATEGORY_VISIBILITY,
|
||||
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
||||
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
|
||||
// Sort
|
||||
@ -425,7 +426,6 @@ export const APP_CONFIGURATION = {
|
||||
// Display
|
||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
showExifInfo: SHOW_EXIF_DATA,
|
||||
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||
showSocial: SHOW_SOCIAL,
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
KEY_CREDENTIALS_SUCCESS,
|
||||
} from '.';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
|
||||
import IconLock from '@/components/icons/IconLock';
|
||||
|
||||
@ -30,7 +30,7 @@ export default async function CameraHeader({
|
||||
entity={<PhotoCamera
|
||||
{...{ camera }}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityDescription={
|
||||
descriptionForCameraPhotos(
|
||||
|
||||
@ -1,27 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { AiFillApple } from 'react-icons/ai';
|
||||
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
||||
import { pathForCamera } from '@/app/paths';
|
||||
import { Camera, formatCameraText } from '.';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconCamera from '@/components/icons/IconCamera';
|
||||
import { isCameraApple } from '@/platforms/apple';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoCamera({
|
||||
camera,
|
||||
hideAppleIcon,
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
camera: Camera
|
||||
hideAppleIcon?: boolean
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
const isApple = isCameraApple(camera);
|
||||
const showAppleIcon = !hideAppleIcon && isApple;
|
||||
|
||||
@ -30,9 +25,7 @@ export default function PhotoCamera({
|
||||
{...props}
|
||||
label={formatCameraText(camera)}
|
||||
path={pathForCamera(camera)}
|
||||
tooltipImagePath={pathForCameraImage(camera)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ camera }}
|
||||
icon={showAppleIcon
|
||||
? <AiFillApple
|
||||
title="Apple"
|
||||
@ -43,7 +36,6 @@ export default function PhotoCamera({
|
||||
size={15}
|
||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||
/>}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { createCameraKey, Camera } from '@/camera';
|
||||
import { createLensKey, Lens } from '@/lens';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useCallback } from 'react';
|
||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
|
||||
export default function useCategoryCounts() {
|
||||
const { categoriesWithCounts } = useAppState();
|
||||
|
||||
@ -41,7 +41,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { IoInvertModeSharp } from 'react-icons/io5';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { searchPhotosAction } from '@/photo/actions';
|
||||
import { RiToolsFill } from 'react-icons/ri';
|
||||
import { signOutAction } from '@/auth/actions';
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import { Variant, motion } from 'framer-motion';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||
|
||||
const IGNORE_CAN_START = true;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
|
||||
import { FiUploadCloud } from 'react-icons/fi';
|
||||
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
|
||||
import ProgressButton from './primitives/ProgressButton';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
export default function ImageInput({
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { clsx } from 'clsx/lite';
|
||||
import SimpleCheckbox from './primitives/SimpleCheckbox';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import Spinner from './Spinner';
|
||||
|
||||
export default function SelectTileOverlay({
|
||||
|
||||
153
src/components/entity/EntityHover.tsx
Normal file
153
src/components/entity/EntityHover.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { ComponentProps, ReactNode, useMemo } from 'react';
|
||||
import SharedHover from '../shared-hover/SharedHover';
|
||||
import { Photo, photoQuantityText } from '@/photo';
|
||||
import { useSharedHoverState } from '../shared-hover/state';
|
||||
import useSWR from 'swr';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
import PhotoMedium from '@/photo/PhotoMedium';
|
||||
import Spinner from '../Spinner';
|
||||
import clsx from 'clsx';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { SWR_KEY_SHARED_HOVER } from '@/swr';
|
||||
|
||||
const { width, height } = getDimensionsFromSize(300, 16 / 9);
|
||||
|
||||
export default function EntityHover({
|
||||
hoverKey,
|
||||
header,
|
||||
getPhotos,
|
||||
photosCount,
|
||||
children,
|
||||
color,
|
||||
}: {
|
||||
hoverKey: string
|
||||
header: ReactNode
|
||||
getPhotos: () => Promise<Photo[]>
|
||||
photosCount: number
|
||||
color?: ComponentProps<typeof SharedHover>['color']
|
||||
children: ReactNode
|
||||
}) {
|
||||
const appText = useAppText();
|
||||
|
||||
const { isHoverBeingShown } = useSharedHoverState();
|
||||
|
||||
const isHovering = isHoverBeingShown?.(hoverKey);
|
||||
|
||||
const {
|
||||
data: photos,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
isHovering ? `${SWR_KEY_SHARED_HOVER}-${hoverKey}` : null,
|
||||
getPhotos, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
const photosToShow = useMemo(() => {
|
||||
if (photosCount >= 6) {
|
||||
return 6;
|
||||
} else if (photosCount >= 4) {
|
||||
return 4;
|
||||
} else if (photosCount >= 2) {
|
||||
return 2;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}, [photosCount]);
|
||||
|
||||
const gridClass = useMemo(() => {
|
||||
if (photosCount >= 6) {
|
||||
return 'grid-cols-3 grid-rows-2';
|
||||
} else if (photosCount >= 4) {
|
||||
return 'grid-cols-2 grid-rows-2';
|
||||
} else if (photosCount >= 2) {
|
||||
return 'grid-cols-2';
|
||||
} else {
|
||||
return 'grid-cols-1';
|
||||
}
|
||||
}, [photosCount]);
|
||||
|
||||
const content = useMemo(() =>
|
||||
<div className="relative w-full h-full">
|
||||
{/* Photo grid */}
|
||||
<div className={clsx('absolute inset-0 grid', gridClass)}>
|
||||
{Array.from({ length: photosToShow }).map((_, index) =>
|
||||
photos?.[index] &&
|
||||
<PhotoMedium
|
||||
key={photos[index].id}
|
||||
photo={photos[index]}
|
||||
/>)}
|
||||
</div>
|
||||
{/* Placeholder grid */}
|
||||
<div className={clsx(
|
||||
'absolute inset-0 grid',
|
||||
gridClass,
|
||||
'transition-opacity duration-300',
|
||||
photos ? 'opacity-0' : 'opacity-100',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
)}>
|
||||
{Array.from({ length: photosToShow }).map((_, index) =>
|
||||
<div
|
||||
key={index}
|
||||
className="border-main border-[0.5px]"
|
||||
/>)}
|
||||
</div>
|
||||
{/* Text guard */}
|
||||
<div className={clsx(
|
||||
'absolute inset-0 transition-colors duration-300',
|
||||
'bg-gradient-to-b',
|
||||
photos ? 'from-black/70' : 'from-black/30',
|
||||
'to-transparent',
|
||||
)} />
|
||||
{/* Text */}
|
||||
<div className={clsx(
|
||||
'absolute inset-0 p-2.5',
|
||||
)}>
|
||||
<div className="flex flex-col gap-1 h-full">
|
||||
{/* Header */}
|
||||
<div className="grow">
|
||||
<span className={clsx(
|
||||
'flex text-base',
|
||||
'grow',
|
||||
'translate-x-[4px]',
|
||||
)}>
|
||||
{header}
|
||||
</span>
|
||||
</div>
|
||||
{/* Caption */}
|
||||
<div className={clsx(
|
||||
'self-start',
|
||||
'flex items-center gap-2',
|
||||
'px-1.5 py-0.5 rounded-sm',
|
||||
'text-white/90 bg-black/40 backdrop-blur-lg',
|
||||
'outline-medium shadow-sm',
|
||||
'uppercase text-[0.7rem]',
|
||||
)}>
|
||||
{photoQuantityText(photosCount, appText, false)}
|
||||
{isLoading &&
|
||||
<Spinner size={9} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
, [
|
||||
gridClass,
|
||||
photosToShow,
|
||||
photos,
|
||||
header,
|
||||
photosCount,
|
||||
appText,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
return <SharedHover {...{
|
||||
hoverKey,
|
||||
content,
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
}} >
|
||||
{children}
|
||||
</SharedHover>;
|
||||
}
|
||||
@ -1,46 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
|
||||
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
|
||||
import LabeledIcon, { LabeledIconType } from '../primitives/LabeledIcon';
|
||||
import Badge from '../Badge';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import LinkWithStatus from '../LinkWithStatus';
|
||||
import Spinner from '../Spinner';
|
||||
import ResponsiveText from './ResponsiveText';
|
||||
import OGTooltip from '../og/OGTooltip';
|
||||
import ResponsiveText from '../primitives/ResponsiveText';
|
||||
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
|
||||
import EntityHover from './EntityHover';
|
||||
import { getPhotosCachedAction } from '@/photo/actions';
|
||||
import { PhotoQueryOptions } from '@/photo/db';
|
||||
import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response';
|
||||
|
||||
export interface EntityLinkExternalProps {
|
||||
ref?: RefObject<HTMLSpanElement | null>
|
||||
type?: LabeledIconType
|
||||
badged?: boolean
|
||||
contrast?: ComponentProps<typeof Badge>['contrast']
|
||||
showTooltip?: boolean
|
||||
uppercase?: boolean
|
||||
prefetch?: boolean
|
||||
suppressSpinner?: boolean
|
||||
className?: string
|
||||
countOnHover?: number
|
||||
showHover?: boolean
|
||||
hoverPhotoQueryOptions?: PhotoQueryOptions
|
||||
}
|
||||
|
||||
export default function EntityLink({
|
||||
ref,
|
||||
icon,
|
||||
iconBadge,
|
||||
iconBadgeStart,
|
||||
iconBadgeEnd,
|
||||
label,
|
||||
labelSmall,
|
||||
labelComplex,
|
||||
iconWide,
|
||||
type,
|
||||
badged,
|
||||
contrast = 'medium',
|
||||
showTooltip = SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
path = '', // Make link optional for debugging purposes
|
||||
tooltipImagePath,
|
||||
tooltipCaption,
|
||||
showHover = SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
countOnHover,
|
||||
hoverPhotoQueryOptions,
|
||||
prefetch,
|
||||
title,
|
||||
action,
|
||||
hoverEntity,
|
||||
truncate = true,
|
||||
className,
|
||||
classNameIcon,
|
||||
@ -49,18 +53,15 @@ export default function EntityLink({
|
||||
debug,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
iconBadge?: ReactNode
|
||||
iconBadgeStart?: ReactNode
|
||||
iconBadgeEnd?: ReactNode
|
||||
label: string
|
||||
labelSmall?: ReactNode
|
||||
labelComplex?: ReactNode
|
||||
iconWide?: boolean
|
||||
path?: string
|
||||
tooltipImagePath?: string
|
||||
tooltipCaption?: ReactNode
|
||||
prefetch?: boolean
|
||||
title?: string
|
||||
action?: ReactNode
|
||||
hoverEntity?: ReactNode
|
||||
truncate?: boolean
|
||||
className?: string
|
||||
classNameIcon?: string
|
||||
@ -69,6 +70,8 @@ export default function EntityLink({
|
||||
} & EntityLinkExternalProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasBadgeIcon = Boolean(iconBadgeStart || iconBadgeEnd);
|
||||
|
||||
const classForContrast = () => {
|
||||
switch (contrast) {
|
||||
case 'low':
|
||||
@ -84,15 +87,15 @@ export default function EntityLink({
|
||||
|
||||
const showHoverEntity =
|
||||
!isLoading &&
|
||||
hoverEntity !== undefined &&
|
||||
!showTooltip;
|
||||
countOnHover &&
|
||||
!showHover;
|
||||
|
||||
const renderLabel =
|
||||
<ResponsiveText shortText={labelSmall}>
|
||||
{labelComplex || label}
|
||||
{label}
|
||||
</ResponsiveText>;
|
||||
|
||||
const renderLink =
|
||||
const renderLink = (useForHover?: boolean) =>
|
||||
<LinkWithStatus
|
||||
href={path}
|
||||
className={clsx(
|
||||
@ -107,28 +110,33 @@ export default function EntityLink({
|
||||
setIsLoading={setIsLoading}
|
||||
>
|
||||
<LabeledIcon {...{
|
||||
icon,
|
||||
iconWide,
|
||||
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
|
||||
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
|
||||
prefetch,
|
||||
title,
|
||||
type,
|
||||
type: useForHover ? 'icon-first' : type,
|
||||
uppercase,
|
||||
classNameIcon: clsx('text-dim', classNameIcon),
|
||||
className: useForHover ? 'text-white' : undefined,
|
||||
classNameIcon: clsx(
|
||||
!useForHover && 'text-dim',
|
||||
classNameIcon,
|
||||
),
|
||||
debug,
|
||||
}}>
|
||||
{badged
|
||||
{badged && !useForHover
|
||||
? <Badge
|
||||
type="small"
|
||||
contrast={contrast}
|
||||
className={clsx(
|
||||
'translate-y-[-0.5px]',
|
||||
iconBadge && '*:flex *:items-center *:gap-1',
|
||||
hasBadgeIcon && '*:flex *:items-center *:gap-1',
|
||||
)}
|
||||
uppercase
|
||||
interactive
|
||||
>
|
||||
{iconBadge}
|
||||
{iconBadgeStart}
|
||||
{renderLabel}
|
||||
{iconBadgeEnd}
|
||||
</Badge>
|
||||
: <span className={clsx(
|
||||
'text-content',
|
||||
@ -152,23 +160,28 @@ export default function EntityLink({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showTooltip && tooltipImagePath
|
||||
? <OGTooltip
|
||||
title={label}
|
||||
path={tooltipImagePath}
|
||||
caption={tooltipCaption}
|
||||
{showHover && countOnHover && hoverPhotoQueryOptions
|
||||
? <EntityHover
|
||||
hoverKey={path}
|
||||
header={renderLink(true)}
|
||||
photosCount={countOnHover}
|
||||
getPhotos={() =>
|
||||
getPhotosCachedAction({
|
||||
...hoverPhotoQueryOptions,
|
||||
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||
})}
|
||||
color={contrast === 'frosted' ? 'frosted' : undefined}
|
||||
>
|
||||
{renderLink}
|
||||
</OGTooltip>
|
||||
: renderLink}
|
||||
{renderLink()}
|
||||
</EntityHover>
|
||||
: renderLink()}
|
||||
{action &&
|
||||
<span className="action">
|
||||
{action}
|
||||
</span>}
|
||||
{showHoverEntity &&
|
||||
<span className="hidden peer-hover:inline text-dim">
|
||||
{hoverEntity}
|
||||
{countOnHover}
|
||||
</span>}
|
||||
{isLoading && !suppressSpinner &&
|
||||
<Spinner
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import { BLUR_ENABLED } from '@/app/config';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { clsx} from 'clsx/lite';
|
||||
import Image, { ImageProps } from 'next/image';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
export default function ImageWithFallback({
|
||||
className,
|
||||
classNameImage = 'object-cover h-full',
|
||||
forceFallbackFade = false,
|
||||
blurDataURL,
|
||||
blurCompatibilityLevel = 'low',
|
||||
priority,
|
||||
@ -17,12 +18,14 @@ export default function ImageWithFallback({
|
||||
}: ImageProps & {
|
||||
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
||||
classNameImage?: string
|
||||
forceFallbackFade?: boolean
|
||||
}) {
|
||||
const { shouldDebugImageFallbacks } = useAppState();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [didError, setDidError] = useState(false);
|
||||
const [fadeFallbackTransition, setFadeFallbackTransition] = useState(false);
|
||||
const [fadeFallbackTransition, setFadeFallbackTransition] =
|
||||
useState(forceFallbackFade);
|
||||
|
||||
const onLoad = useCallback(() => setIsLoading(false), []);
|
||||
const onError = useCallback(() => setDidError(true), []);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import useMetaThemeColor from '@/utility/useMetaThemeColor';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import {
|
||||
ComponentProps,
|
||||
RefObject,
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import { ComponentProps, ReactNode, useRef, useEffect } from 'react';
|
||||
import OGLoaderImage from './OGLoaderImage';
|
||||
import { IMAGE_OG_DIMENSION } from '@/image-response';
|
||||
import clsx from 'clsx/lite';
|
||||
import { Tooltip, 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,
|
||||
caption,
|
||||
color,
|
||||
...props
|
||||
}: {
|
||||
children :ReactNode
|
||||
caption?: ReactNode
|
||||
color?: Tooltip['color']
|
||||
} & ComponentProps<typeof OGLoaderImage>) {
|
||||
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]',
|
||||
color === 'frosted'
|
||||
? 'outline outline-gray-400/25'
|
||||
: '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, color },
|
||||
)}
|
||||
onMouseLeave={() => supportsHover &&
|
||||
dismissTooltip?.(ref.current)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
'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-100 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!"
|
||||
color={currentTooltip.color}
|
||||
>
|
||||
{currentTooltip.content}
|
||||
</MenuSurface>
|
||||
</motion.div>}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{children}
|
||||
</OGTooltipContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import { ComponentProps, createContext, ReactNode, use } from 'react';
|
||||
import MenuSurface from '../primitives/MenuSurface';
|
||||
|
||||
export type Tooltip = {
|
||||
content: ReactNode
|
||||
width: number
|
||||
height: number
|
||||
offsetAbove: number
|
||||
offsetBelow: number
|
||||
color?: ComponentProps<typeof MenuSurface>['color']
|
||||
}
|
||||
|
||||
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);
|
||||
67
src/components/shared-hover/SharedHover.tsx
Normal file
67
src/components/shared-hover/SharedHover.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { ReactNode, useRef, useEffect } from 'react';
|
||||
import { SharedHoverProps, useSharedHoverState } from '../shared-hover/state';
|
||||
import useSupportsHover from '@/utility/useSupportsHover';
|
||||
|
||||
export default function SharedHover({
|
||||
hoverKey: key,
|
||||
children,
|
||||
content,
|
||||
width,
|
||||
height,
|
||||
offsetAbove = -1,
|
||||
offsetBelow = -6,
|
||||
color,
|
||||
}: {
|
||||
hoverKey: string
|
||||
children :ReactNode
|
||||
content: ReactNode
|
||||
width: number
|
||||
height: number
|
||||
offsetAbove?: number
|
||||
offsetBelow?: number
|
||||
color?: SharedHoverProps['color']
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
showHover,
|
||||
dismissHover,
|
||||
renderHover,
|
||||
isHoverBeingShown,
|
||||
} = useSharedHoverState();
|
||||
|
||||
const isHovering = isHoverBeingShown?.(key);
|
||||
|
||||
const supportsHover = useSupportsHover();
|
||||
|
||||
useEffect(() => {
|
||||
const trigger = ref.current;
|
||||
return () => dismissHover?.(trigger);
|
||||
}, [dismissHover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovering) {
|
||||
renderHover?.(content);
|
||||
}
|
||||
}, [isHovering, renderHover, content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-full"
|
||||
ref={ref}
|
||||
onMouseEnter={() => supportsHover &&
|
||||
showHover?.(ref.current, {
|
||||
key,
|
||||
width,
|
||||
height,
|
||||
offsetAbove,
|
||||
offsetBelow,
|
||||
color,
|
||||
})}
|
||||
onMouseLeave={() => supportsHover &&
|
||||
dismissHover?.(ref.current)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
src/components/shared-hover/SharedHoverProvider.tsx
Normal file
168
src/components/shared-hover/SharedHoverProvider.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { SharedHoverContext, SharedHoverProps } from './state';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import MenuSurface from '../primitives/MenuSurface';
|
||||
import clsx from 'clsx/lite';
|
||||
|
||||
const WINDOW_CHANGE_EVENTS = ['mouseup', 'mousewheel', 'resize'];
|
||||
|
||||
const DELAY_INITIAL_HOVER = 200;
|
||||
const DELAY_DISMISS = 200;
|
||||
|
||||
const VIEWPORT_SAFE_AREA = 12;
|
||||
const HOVER_MARGIN = 12;
|
||||
|
||||
export default function SharedHoverProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [hoverProps, setHoverProps] = useState<SharedHoverProps>();
|
||||
const [hoverContent, setHoverContent] = useState<ReactNode>();
|
||||
const [hoverStyle, setHoverStyle] = 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(() => {
|
||||
setHoverProps(undefined);
|
||||
currentTriggerRef.current = null;
|
||||
}, delay);
|
||||
} else {
|
||||
setHoverProps(undefined);
|
||||
currentTriggerRef.current = null;
|
||||
}
|
||||
}, [clearTimeouts]);
|
||||
|
||||
const showHover = useCallback((
|
||||
_trigger: HTMLElement | null,
|
||||
hover: SharedHoverProps,
|
||||
) => {
|
||||
if (_trigger) {
|
||||
currentTriggerRef.current = _trigger;
|
||||
const displayHover = () => {
|
||||
// Update current trigger ref on display
|
||||
currentTriggerRef.current = _trigger;
|
||||
setHoverProps(hover);
|
||||
const trigger = _trigger.getBoundingClientRect();
|
||||
const top =
|
||||
trigger.top - (hover.height + HOVER_MARGIN) < VIEWPORT_SAFE_AREA
|
||||
// Position below trigger
|
||||
? trigger.bottom + HOVER_MARGIN + hover.offsetBelow
|
||||
// Position above trigger
|
||||
: trigger.top - (hover.height + HOVER_MARGIN)
|
||||
+ hover.offsetAbove;
|
||||
const horizontalOffset =
|
||||
window.innerWidth - (trigger.left + hover.width) < VIEWPORT_SAFE_AREA
|
||||
? { right: VIEWPORT_SAFE_AREA }
|
||||
: { left: trigger.left };
|
||||
setHoverStyle({ top, ...horizontalOffset });
|
||||
clearTimeouts();
|
||||
};
|
||||
if (hoverProps) {
|
||||
// Don't apply delay if hover is already visible
|
||||
displayHover();
|
||||
} else {
|
||||
timeoutInitialHoverRef.current =
|
||||
setTimeout(displayHover, DELAY_INITIAL_HOVER);
|
||||
}
|
||||
}
|
||||
}, [hoverProps, clearTimeouts]);
|
||||
|
||||
const dismissHover = useCallback((trigger: HTMLElement | null) => {
|
||||
if (trigger === currentTriggerRef.current) {
|
||||
clearState(DELAY_DISMISS);
|
||||
}
|
||||
}, [clearState]);
|
||||
|
||||
const isHoverBeingShown = useCallback((key: string) =>
|
||||
Boolean(hoverProps?.key && hoverProps.key === key)
|
||||
, [hoverProps]);
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowChange = () => clearState(0);
|
||||
WINDOW_CHANGE_EVENTS.forEach(event => {
|
||||
window.addEventListener(event, onWindowChange);
|
||||
});
|
||||
return () => {
|
||||
WINDOW_CHANGE_EVENTS.forEach(event => {
|
||||
window.removeEventListener(event, onWindowChange);
|
||||
});
|
||||
};
|
||||
}, [clearState]);
|
||||
|
||||
return (
|
||||
<SharedHoverContext.Provider
|
||||
value={{
|
||||
showHover,
|
||||
dismissHover,
|
||||
renderHover: setHoverContent,
|
||||
isHoverBeingShown,
|
||||
}}
|
||||
>
|
||||
<div className="relative inset-0 z-100 pointer-events-none">
|
||||
<AnimatePresence>
|
||||
{hoverProps &&
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layoutId="hover"
|
||||
className="fixed"
|
||||
style={hoverStyle}
|
||||
>
|
||||
<MenuSurface
|
||||
className="max-w-none p-1!"
|
||||
color={hoverProps.color}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative rounded-[0.25rem] overflow-clip',
|
||||
hoverProps.color !== 'frosted' && 'bg-extra-dim',
|
||||
)}
|
||||
style={{
|
||||
width: hoverProps.width,
|
||||
height: hoverProps.height,
|
||||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
{hoverContent}
|
||||
{/* Border */}
|
||||
<div className={clsx(
|
||||
'absolute inset-0',
|
||||
'rounded-[0.25rem]',
|
||||
hoverProps.color === 'frosted'
|
||||
? 'border border-gray-400/25'
|
||||
: 'border-medium',
|
||||
)} />
|
||||
</div>
|
||||
</MenuSurface>
|
||||
</motion.div>}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{children}
|
||||
</SharedHoverContext.Provider>
|
||||
);
|
||||
}
|
||||
29
src/components/shared-hover/state.ts
Normal file
29
src/components/shared-hover/state.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
ComponentProps,
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
use,
|
||||
} from 'react';
|
||||
import MenuSurface from '../primitives/MenuSurface';
|
||||
|
||||
export type SharedHoverProps = {
|
||||
key: string
|
||||
width: number
|
||||
height: number
|
||||
offsetAbove: number
|
||||
offsetBelow: number
|
||||
color?: ComponentProps<typeof MenuSurface>['color']
|
||||
}
|
||||
|
||||
export type SharedHoverState = {
|
||||
showHover?: (trigger: HTMLElement | null, hover: SharedHoverProps) => void
|
||||
renderHover?: Dispatch<SetStateAction<ReactNode>>
|
||||
dismissHover?: (trigger: HTMLElement | null) => void
|
||||
isHoverBeingShown?: (key: string) => boolean
|
||||
}
|
||||
|
||||
export const SharedHoverContext = createContext<SharedHoverState>({});
|
||||
|
||||
export const useSharedHoverState = () => use(SharedHoverContext);
|
||||
@ -5,7 +5,7 @@ import { descriptionForFilmPhotos } from '.';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoFilm from '@/film/PhotoFilm';
|
||||
import { getRecipePropsFromPhotos } from '@/recipe';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
@ -43,7 +43,7 @@ export default function FilmHeader({
|
||||
toggleRecipeOverlay={recipeProps
|
||||
? () => setRecipeModalProps?.(recipeProps)
|
||||
: undefined}
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityDescription={descriptionForFilmPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,33 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import PhotoFilmIcon from './PhotoFilmIcon';
|
||||
import { pathForFilm, pathForFilmImage } from '@/app/paths';
|
||||
import { pathForFilm } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import clsx from 'clsx/lite';
|
||||
import { labelForFilm } from '.';
|
||||
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoFilm({
|
||||
film,
|
||||
type = 'icon-last',
|
||||
badged = true,
|
||||
contrast = 'low',
|
||||
countOnHover,
|
||||
toggleRecipeOverlay,
|
||||
isShowingRecipeOverlay,
|
||||
...props
|
||||
}: {
|
||||
film: string
|
||||
countOnHover?: number
|
||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||
& EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
const { small, medium, large } = labelForFilm(film);
|
||||
|
||||
return (
|
||||
@ -36,9 +31,7 @@ export default function PhotoFilm({
|
||||
label={medium}
|
||||
labelSmall={small}
|
||||
path={pathForFilm(film)}
|
||||
tooltipImagePath={pathForFilmImage(film)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ film }}
|
||||
icon={<PhotoFilmIcon
|
||||
film={film}
|
||||
className={clsx(
|
||||
@ -57,7 +50,6 @@ export default function PhotoFilm({
|
||||
toggleRecipeOverlay,
|
||||
isShowingRecipeOverlay,
|
||||
}} />}
|
||||
hoverEntity={countOnHover}
|
||||
iconWide={isStringFujifilmSimulation(film)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -27,7 +27,7 @@ export default async function FocalLengthHeader({
|
||||
entity={<PhotoFocalLength
|
||||
focal={focal}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityDescription={descriptionForFocalLengthPhotos(
|
||||
photos,
|
||||
|
||||
@ -1,34 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
|
||||
import { pathForFocalLength } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import { formatFocalLength } from '.';
|
||||
import IconFocalLength from '@/components/icons/IconFocalLength';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoFocalLength({
|
||||
focal,
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
focal: number
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatFocalLength(focal)}
|
||||
path={pathForFocalLength(focal)}
|
||||
tooltipImagePath={pathForFocalLengthImage(focal)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ focal }}
|
||||
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ export default async function LensHeader({
|
||||
entity={<PhotoLens
|
||||
{...{ lens }}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityDescription={
|
||||
descriptionForLensPhotos(
|
||||
|
||||
@ -1,39 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
||||
import { pathForLens } from '@/app/paths';
|
||||
import { Lens, formatLensText } from '.';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconLens from '@/components/icons/IconLens';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoLens({
|
||||
lens,
|
||||
countOnHover,
|
||||
shortText,
|
||||
...props
|
||||
}: {
|
||||
lens: Lens
|
||||
countOnHover?: number
|
||||
shortText?: boolean
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
||||
path={pathForLens(lens)}
|
||||
tooltipImagePath={pathForLensImage(lens)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ lens }}
|
||||
icon={<IconLens
|
||||
size={14}
|
||||
className="translate-x-[-0.5px]"
|
||||
/>}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,10 +14,15 @@ import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
|
||||
import { Photo } from '.';
|
||||
import { PhotoSetCategory } from '../category';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import useVisible from '@/utility/useVisible';
|
||||
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
|
||||
import { SortBy } from './db/sort';
|
||||
import { SWR_KEY_INFINITE_PHOTO_SCROLL } from '@/swr';
|
||||
|
||||
const SIZE_KEY_SEPARATOR = '__';
|
||||
const getSizeFromKey = (key: string) =>
|
||||
parseInt(key.split(SIZE_KEY_SEPARATOR)[1]);
|
||||
|
||||
export type RevalidatePhoto = (
|
||||
photoId: string,
|
||||
@ -55,22 +60,20 @@ export default function InfinitePhotoScroll({
|
||||
revalidatePhoto?: RevalidatePhoto
|
||||
}) => ReactNode
|
||||
} & PhotoSetCategory) {
|
||||
const { swrTimestamp, isUserSignedIn } = useAppState();
|
||||
|
||||
const key = `${swrTimestamp}-${cacheKey}`;
|
||||
const { isUserSignedIn } = useAppState();
|
||||
|
||||
const keyGenerator = useCallback(
|
||||
(size: number, prev: Photo[]) => prev && prev.length === 0
|
||||
? null
|
||||
: [key, size]
|
||||
, [key]);
|
||||
: `${SWR_KEY_INFINITE_PHOTO_SCROLL}-${cacheKey}__${size}`
|
||||
, [cacheKey]);
|
||||
|
||||
const fetcher = useCallback((
|
||||
[_key, size]: [string, number],
|
||||
keyWithSize: string,
|
||||
warmOnly?: boolean,
|
||||
) =>
|
||||
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
|
||||
offset: initialOffset + size * itemsPerPage,
|
||||
offset: initialOffset + getSizeFromKey(keyWithSize) * itemsPerPage,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
limit: itemsPerPage,
|
||||
@ -111,7 +114,7 @@ export default function InfinitePhotoScroll({
|
||||
|
||||
useEffect(() => {
|
||||
if (ADMIN_DB_OPTIMIZE_ENABLED) {
|
||||
fetcher(['', 0], true);
|
||||
fetcher(`${SIZE_KEY_SEPARATOR}0`, true);
|
||||
}
|
||||
}, [fetcher]);
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import PhotoMedium from './PhotoMedium';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { GRID_ASPECT_RATIO } from '@/app/config';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import SelectTileOverlay from '@/components/SelectTileOverlay';
|
||||
import { ReactNode } from 'react';
|
||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
@ -14,7 +14,7 @@ import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
export default function PhotoGrid({
|
||||
photos,
|
||||
selectedPhoto,
|
||||
photoPriority,
|
||||
prioritizeInitialPhotos,
|
||||
animate = true,
|
||||
canStart,
|
||||
animateOnFirstLoadOnly,
|
||||
@ -28,7 +28,7 @@ export default function PhotoGrid({
|
||||
}: {
|
||||
photos: Photo[]
|
||||
selectedPhoto?: Photo
|
||||
photoPriority?: boolean
|
||||
prioritizeInitialPhotos?: boolean
|
||||
animate?: boolean
|
||||
canStart?: boolean
|
||||
animateOnFirstLoadOnly?: boolean
|
||||
@ -90,7 +90,7 @@ export default function PhotoGrid({
|
||||
photo,
|
||||
...categories,
|
||||
selected: photo.id === selectedPhoto?.id,
|
||||
priority: photoPriority,
|
||||
priority: prioritizeInitialPhotos ? index < 6 : undefined,
|
||||
onVisible: index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
: undefined,
|
||||
|
||||
@ -5,7 +5,7 @@ import { PATH_GRID_INFERRED } from '@/app/paths';
|
||||
import PhotoGridSidebar from './PhotoGridSidebar';
|
||||
import PhotoGridContainer from './PhotoGridContainer';
|
||||
import { ComponentProps, useEffect, useRef } from 'react';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import clsx from 'clsx/lite';
|
||||
import useElementHeight from '@/utility/useElementHeight';
|
||||
import MaskedScroll from '@/components/MaskedScroll';
|
||||
@ -42,6 +42,7 @@ export default function PhotoGridPageClient({
|
||||
count={photosCount}
|
||||
sortBy={sortBy}
|
||||
sortWithPriority={sortWithPriority}
|
||||
prioritizeInitialPhotos
|
||||
sidebar={
|
||||
<MaskedScroll
|
||||
ref={ref}
|
||||
|
||||
@ -7,10 +7,14 @@ import { photoQuantityText } from '.';
|
||||
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
||||
import PhotoFilm from '@/film/PhotoFilm';
|
||||
import FavsTag from '../tag/FavsTag';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import HiddenTag from '@/tag/HiddenTag';
|
||||
import { CATEGORY_VISIBILITY, HIDE_TAGS_WITH_ONE_PHOTO } from '@/app/config';
|
||||
import {
|
||||
CATEGORY_VISIBILITY,
|
||||
HIDE_TAGS_WITH_ONE_PHOTO,
|
||||
SHOW_CATEGORY_IMAGE_HOVERS,
|
||||
} from '@/app/config';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
||||
import IconCamera from '@/components/icons/IconCamera';
|
||||
@ -124,7 +128,7 @@ export default function PhotoGridSidebar({
|
||||
<PhotoYear
|
||||
key={year}
|
||||
year={year}
|
||||
countOnHover={count}
|
||||
countOnHover={SHOW_CATEGORY_IMAGE_HOVERS ? count : undefined}
|
||||
type="text-only"
|
||||
prefetch={false}
|
||||
contrast="low"
|
||||
|
||||
@ -15,7 +15,7 @@ import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||
import PhotoPrevNextActions from './PhotoPrevNextActions';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ import { RevalidatePhoto } from './InfinitePhotoScroll';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import useVisible from '@/utility/useVisible';
|
||||
import PhotoDate from './PhotoDate';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { LuExpand } from 'react-icons/lu';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
@ -80,6 +80,7 @@ export default function PhotoLarge({
|
||||
shouldShareRecipe,
|
||||
shouldShareFocalLength,
|
||||
includeFavoriteInAdminMenu,
|
||||
forceFallbackFade,
|
||||
onVisible,
|
||||
showAdminKeyCommands,
|
||||
}: {
|
||||
@ -110,6 +111,7 @@ export default function PhotoLarge({
|
||||
shouldShareRecipe?: boolean
|
||||
shouldShareFocalLength?: boolean
|
||||
includeFavoriteInAdminMenu?: boolean
|
||||
forceFallbackFade?: boolean
|
||||
onVisible?: () => void
|
||||
showAdminKeyCommands?: boolean
|
||||
}) {
|
||||
@ -232,6 +234,7 @@ export default function PhotoLarge({
|
||||
blurDataURL={photo.blurData}
|
||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||
priority={priority}
|
||||
forceFallbackFade={forceFallbackFade}
|
||||
/>
|
||||
</ZoomControls>
|
||||
<div className={clsx(
|
||||
|
||||
@ -4,7 +4,7 @@ import { ReactNode, ComponentProps, RefObject } from 'react';
|
||||
import { Photo, titleForPhoto } from '@/photo';
|
||||
import { PhotoSetCategory } from '@/category';
|
||||
import { AnimationConfig } from '../components/AnimateItems';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { pathForPhoto } from '@/app/paths';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import LinkWithStatus from '@/components/LinkWithStatus';
|
||||
|
||||
@ -21,6 +21,7 @@ export default function PhotoMedium({
|
||||
priority,
|
||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||
className,
|
||||
forceFallbackFade,
|
||||
onVisible,
|
||||
...categories
|
||||
}: {
|
||||
@ -29,6 +30,7 @@ export default function PhotoMedium({
|
||||
priority?: boolean
|
||||
prefetch?: boolean
|
||||
className?: string
|
||||
forceFallbackFade?: boolean
|
||||
onVisible?: () => void
|
||||
} & PhotoSetCategory) {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
@ -66,6 +68,7 @@ export default function PhotoMedium({
|
||||
classNameImage="object-cover w-full h-full"
|
||||
alt={altTextForPhoto(photo)}
|
||||
priority={priority}
|
||||
forceFallbackFade={forceFallbackFade}
|
||||
/>
|
||||
</div>}
|
||||
</LinkWithStatus>
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { PhotoSetCategory } from '../category';
|
||||
import PhotoLink from './PhotoLink';
|
||||
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
|
||||
@ -17,6 +17,7 @@ export default function PhotoSmall({
|
||||
selected,
|
||||
className,
|
||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||
forceFallbackFade,
|
||||
onVisible,
|
||||
...categories
|
||||
}: {
|
||||
@ -24,6 +25,7 @@ export default function PhotoSmall({
|
||||
selected?: boolean
|
||||
className?: string
|
||||
prefetch?: boolean
|
||||
forceFallbackFade?: boolean
|
||||
onVisible?: () => void
|
||||
} & PhotoSetCategory) {
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
@ -50,6 +52,7 @@ export default function PhotoSmall({
|
||||
blurDataURL={photo.blurData}
|
||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||
alt={altTextForPhoto(photo)}
|
||||
forceFallbackFade={forceFallbackFade}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
|
||||
import ImageInput from '../components/ImageInput';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { RefObject, useTransition, useRef, useEffect } from 'react';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
renamePhotoRecipeGlobally,
|
||||
getPhotosNeedingRecipeTitleCount,
|
||||
} from '@/photo/db/query';
|
||||
import { GetPhotosOptions, areOptionsSensitive } from './db';
|
||||
import { PhotoQueryOptions, areOptionsSensitive } from './db';
|
||||
import {
|
||||
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
|
||||
PhotoFormData,
|
||||
@ -546,7 +546,7 @@ export const getImageBlurAction = async (url: string) =>
|
||||
// Public/Private actions
|
||||
|
||||
export const getPhotosAction = async (
|
||||
options: GetPhotosOptions,
|
||||
options: PhotoQueryOptions,
|
||||
warmOnly?: boolean,
|
||||
) => {
|
||||
if (warmOnly) {
|
||||
@ -559,7 +559,7 @@ export const getPhotosAction = async (
|
||||
};
|
||||
|
||||
export const getPhotosCachedAction = async (
|
||||
options: GetPhotosOptions,
|
||||
options: PhotoQueryOptions,
|
||||
warmOnly?: boolean,
|
||||
) => {
|
||||
if (warmOnly) {
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
getUniqueRecipes,
|
||||
getUniqueYears,
|
||||
} from '@/photo/db/query';
|
||||
import { GetPhotosOptions } from './db';
|
||||
import { PhotoQueryOptions } from './db';
|
||||
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
||||
import { createCameraKey } from '@/camera';
|
||||
import {
|
||||
@ -54,9 +54,9 @@ const KEY_YEARS = 'years';
|
||||
const KEY_COUNT = 'count';
|
||||
const KEY_DATE_RANGE = 'date-range';
|
||||
|
||||
const getPhotosCacheKeyForOption = (
|
||||
options: GetPhotosOptions,
|
||||
option: keyof GetPhotosOptions,
|
||||
const getCacheKeyForPhotoQueryOptions = (
|
||||
options: PhotoQueryOptions,
|
||||
option: keyof PhotoQueryOptions,
|
||||
): string | null => {
|
||||
switch (option) {
|
||||
// Complex keys
|
||||
@ -81,13 +81,13 @@ const getPhotosCacheKeyForOption = (
|
||||
}
|
||||
};
|
||||
|
||||
const getPhotosCacheKeys = (options: GetPhotosOptions = {}) => {
|
||||
const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
|
||||
const tags: string[] = [];
|
||||
|
||||
Object.keys(options).forEach(key => {
|
||||
const tag = getPhotosCacheKeyForOption(
|
||||
const tag = getCacheKeyForPhotoQueryOptions(
|
||||
options,
|
||||
key as keyof GetPhotosOptions,
|
||||
key as keyof PhotoQueryOptions,
|
||||
);
|
||||
if (tag) { tags.push(tag); }
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ const parameterizeForDb = (field: string) =>
|
||||
`REPLACE(${acc}, '${from}', '${to}')`
|
||||
, `LOWER(TRIM(${field}))`);
|
||||
|
||||
export type GetPhotosOptions = {
|
||||
export type PhotoQueryOptions = {
|
||||
sortBy?: SortBy
|
||||
sortWithPriority?: boolean
|
||||
limit?: number
|
||||
@ -35,11 +35,11 @@ export type GetPhotosOptions = {
|
||||
lens?: Partial<Lens>
|
||||
};
|
||||
|
||||
export const areOptionsSensitive = (options: GetPhotosOptions) =>
|
||||
export const areOptionsSensitive = (options: PhotoQueryOptions) =>
|
||||
options.hidden === 'include' || options.hidden === 'only';
|
||||
|
||||
export const getWheresFromOptions = (
|
||||
options: GetPhotosOptions,
|
||||
options: PhotoQueryOptions,
|
||||
initialValuesIndex = 1,
|
||||
) => {
|
||||
const {
|
||||
@ -149,7 +149,7 @@ export const getWheresFromOptions = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
||||
export const getOrderByFromOptions = (options: PhotoQueryOptions) => {
|
||||
const {
|
||||
sortBy = APP_DEFAULT_SORT_BY,
|
||||
sortWithPriority,
|
||||
@ -176,7 +176,7 @@ export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
||||
};
|
||||
|
||||
export const getLimitAndOffsetFromOptions = (
|
||||
options: GetPhotosOptions,
|
||||
options: PhotoQueryOptions,
|
||||
initialValuesIndex = 1,
|
||||
) => {
|
||||
const {
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
AI_TEXT_GENERATION_ENABLED,
|
||||
} from '@/app/config';
|
||||
import {
|
||||
GetPhotosOptions,
|
||||
PhotoQueryOptions,
|
||||
getOrderByFromOptions,
|
||||
getLimitAndOffsetFromOptions,
|
||||
getWheresFromOptions,
|
||||
@ -80,7 +80,7 @@ const createPhotosTable = () =>
|
||||
const safelyQueryPhotos = async <T>(
|
||||
callback: () => Promise<T>,
|
||||
queryLabel: string,
|
||||
queryOptions?: GetPhotosOptions,
|
||||
queryOptions?: PhotoQueryOptions,
|
||||
): Promise<T> => {
|
||||
let result: T;
|
||||
|
||||
@ -489,7 +489,7 @@ export const getUniqueFocalLengths = async () =>
|
||||
})))
|
||||
, 'getUniqueFocalLengths');
|
||||
|
||||
export const getPhotos = async (options: GetPhotosOptions = {}) =>
|
||||
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
|
||||
safelyQueryPhotos(async () => {
|
||||
const sql = ['SELECT * FROM photos'];
|
||||
const values = [] as (string | number)[];
|
||||
@ -526,7 +526,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) =>
|
||||
|
||||
export const getPhotosNearId = async (
|
||||
photoId: string,
|
||||
options: GetPhotosOptions,
|
||||
options: PhotoQueryOptions,
|
||||
) =>
|
||||
safelyQueryPhotos(async () => {
|
||||
const { limit } = options;
|
||||
@ -565,7 +565,7 @@ export const getPhotosNearId = async (
|
||||
});
|
||||
}, `getPhotosNearId: ${photoId}`);
|
||||
|
||||
export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
|
||||
export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
|
||||
safelyQueryPhotos(async () => {
|
||||
// eslint-disable-next-line max-len
|
||||
let sql = 'SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos';
|
||||
|
||||
@ -33,7 +33,7 @@ import { AiContent } from '../ai/useAiImageQueries';
|
||||
import AiButton from '../ai/AiButton';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import usePreventNavigation from '@/utility/usePreventNavigation';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
||||
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
||||
@ -327,6 +327,7 @@ export default function PhotoForm({
|
||||
onSubmit={() => {
|
||||
setFormActionErrorMessage('');
|
||||
(document.activeElement as HTMLElement)?.blur?.();
|
||||
invalidateSwr?.();
|
||||
}}
|
||||
>
|
||||
{/* Fields */}
|
||||
@ -473,7 +474,6 @@ export default function PhotoForm({
|
||||
icon={type === 'create' && <IconAddUpload />}
|
||||
disabled={!canFormBeSubmitted}
|
||||
onFormStatusChange={onFormStatusChange}
|
||||
onFormSubmit={invalidateSwr}
|
||||
primary
|
||||
>
|
||||
{type === 'create' ? 'Add' : 'Update'}
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
|
||||
import { PREFIX_RECENTS } from '@/app/paths';
|
||||
import EntityLink, { EntityLinkExternalProps } from
|
||||
'@/components/primitives/EntityLink';
|
||||
'@/components/entity/EntityLink';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
import IconRecents from '@/components/icons/IconRecents';
|
||||
|
||||
export default function PhotoRecents({
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
export default function PhotoRecents(props: EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
@ -18,12 +12,9 @@ export default function PhotoRecents({
|
||||
{...props}
|
||||
label={appText.category.recentPlural}
|
||||
path={PREFIX_RECENTS}
|
||||
tooltipImagePath={pathForRecentsImage()}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ recent: true }}
|
||||
icon={<IconRecents size={16} />}
|
||||
iconBadge={<IconRecents size={10} solid />}
|
||||
hoverEntity={countOnHover}
|
||||
iconBadgeStart={<IconRecents size={10} solid />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -24,7 +24,7 @@ export default function RecentsHeader({
|
||||
return (
|
||||
<PhotoHeader
|
||||
recent={true}
|
||||
entity={<PhotoRecents showTooltip={false} />}
|
||||
entity={<PhotoRecents showHover={false} />}
|
||||
entityDescription={descriptionForPhotoSet(
|
||||
photos,
|
||||
appText,
|
||||
|
||||
@ -1,31 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
||||
import { pathForRecipe } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import { formatRecipe } from '.';
|
||||
import clsx from 'clsx/lite';
|
||||
import { ComponentProps } from 'react';
|
||||
import IconRecipe from '@/components/icons/IconRecipe';
|
||||
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoRecipe({
|
||||
ref,
|
||||
recipe,
|
||||
countOnHover,
|
||||
toggleRecipeOverlay,
|
||||
isShowingRecipeOverlay,
|
||||
...props
|
||||
}: {
|
||||
recipe: string
|
||||
countOnHover?: number
|
||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||
& EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
@ -33,13 +27,11 @@ export default function PhotoRecipe({
|
||||
title="Recipe"
|
||||
label={formatRecipe(recipe)}
|
||||
path={pathForRecipe(recipe)}
|
||||
tooltipImagePath={pathForRecipeImage(recipe)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ recipe }}
|
||||
icon={<IconRecipe
|
||||
size={16}
|
||||
className={clsx(
|
||||
props.badged && 'translate-x-[-1px] translate-y-[0.5px]',
|
||||
props.badged && 'translate-x-[-1px] translate-y-[-1px]',
|
||||
)}
|
||||
/>}
|
||||
action={toggleRecipeOverlay &&
|
||||
@ -47,7 +39,6 @@ export default function PhotoRecipe({
|
||||
toggleRecipeOverlay,
|
||||
isShowingRecipeOverlay,
|
||||
}} />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { Photo, PhotoDateRange } from '@/photo';
|
||||
import PhotoHeader from '@/photo/PhotoHeader';
|
||||
import PhotoRecipe from './PhotoRecipe';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
|
||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
@ -35,7 +35,7 @@ export default function RecipeHeader({
|
||||
entity={<PhotoRecipe
|
||||
recipe={recipe}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
||||
toggleRecipeOverlay={recipeProps
|
||||
? () => setRecipeModalProps?.(recipeProps)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Modal from '@/components/Modal';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
|
||||
|
||||
export default function ShareModals() {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { TbPhotoShare } from 'react-icons/tb';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { getSharePathFromShareModalProps, ShareModalProps } from '.';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@ -10,7 +10,7 @@ import { toastSuccess } from '@/toast';
|
||||
import { PiXLogo } from 'react-icons/pi';
|
||||
import { SHOW_SOCIAL } from '@/app/config';
|
||||
import { generateXPostText } from '@/utility/social';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import useOnPathChange from '@/utility/useOnPathChange';
|
||||
import { IoArrowUp } from 'react-icons/io5';
|
||||
import MaskedScroll from '@/components/MaskedScroll';
|
||||
|
||||
@ -5,7 +5,7 @@ import TagShareModal from '@/tag/TagShareModal';
|
||||
import CameraShareModal from '@/camera/CameraShareModal';
|
||||
import FilmShareModal from '@/film/FilmShareModal';
|
||||
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
||||
import LensShareModal from '@/lens/LensShareModal';
|
||||
import YearShareModal from '@/years/YearShareModal';
|
||||
|
||||
21
src/swr/index.ts
Normal file
21
src/swr/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export const SWR_KEY_GET_AUTH = 'getAuth';
|
||||
export const SWR_KEY_GET_ADMIN_DATA = 'getAdminData';
|
||||
export const SWR_KEY_GET_COUNTS_FOR_CATEGORIES = 'getCountsForCategories';
|
||||
export const SWR_KEY_SHARED_HOVER = 'sharedHover';
|
||||
export const SWR_KEY_INFINITE_PHOTO_SCROLL = 'infinitePhotoScroll';
|
||||
|
||||
const KEYS_THAT_CAN_BE_PURGED = [
|
||||
SWR_KEY_SHARED_HOVER,
|
||||
SWR_KEY_INFINITE_PHOTO_SCROLL,
|
||||
];
|
||||
|
||||
const KEYS_THAT_CAN_BE_PURGED_AND_REVALIDATED = [
|
||||
SWR_KEY_GET_ADMIN_DATA,
|
||||
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
|
||||
];
|
||||
|
||||
export const canKeyBePurged = (key: string) =>
|
||||
KEYS_THAT_CAN_BE_PURGED.some(k => key.startsWith(k));
|
||||
|
||||
export const canKeyBePurgedAndRevalidated = (key: string) =>
|
||||
KEYS_THAT_CAN_BE_PURGED_AND_REVALIDATED.some(k => key.startsWith(k));
|
||||
@ -1,54 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { TAG_FAVS } from '.';
|
||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||
import { pathForTag } from '@/app/paths';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconFavs from '@/components/icons/IconFavs';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function FavsTag({
|
||||
type,
|
||||
badged,
|
||||
contrast,
|
||||
prefetch,
|
||||
countOnHover,
|
||||
className,
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
export default function FavsTag(props: EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={TAG_FAVS}
|
||||
labelComplex={badged &&
|
||||
<span className="inline-flex gap-1 items-center">
|
||||
{TAG_FAVS}
|
||||
<IconFavs
|
||||
size={10}
|
||||
className="translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>
|
||||
</span>}
|
||||
path={pathForTag(TAG_FAVS)}
|
||||
tooltipImagePath={pathForTagImage(TAG_FAVS)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
icon={!badged &&
|
||||
<IconFavs
|
||||
size={13}
|
||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>}
|
||||
type={type}
|
||||
className={className}
|
||||
hoverEntity={countOnHover}
|
||||
badged={badged}
|
||||
contrast={contrast}
|
||||
prefetch={prefetch}
|
||||
hoverPhotoQueryOptions={{ tag: TAG_FAVS }}
|
||||
icon={<IconFavs
|
||||
size={13}
|
||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>}
|
||||
iconBadgeEnd={<IconFavs
|
||||
size={10}
|
||||
className="translate-y-[-0.5px]"
|
||||
highlight
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,37 +3,19 @@ import { pathForTag } from '@/app/paths';
|
||||
import IconHidden from '@/components/icons/IconHidden';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
|
||||
export default function HiddenTag({
|
||||
type,
|
||||
badged,
|
||||
contrast,
|
||||
prefetch,
|
||||
countOnHover,
|
||||
className,
|
||||
}: {
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
export default function HiddenTag(props: EntityLinkExternalProps) {
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={TAG_HIDDEN}
|
||||
labelComplex={badged &&
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{TAG_HIDDEN}
|
||||
<IconHidden
|
||||
size={13}
|
||||
className="translate-y-[-0.5px]"
|
||||
/>
|
||||
</span>}
|
||||
path={pathForTag(TAG_HIDDEN)}
|
||||
icon={!badged && <IconHidden size={16} />}
|
||||
type={type}
|
||||
className={className}
|
||||
hoverEntity={countOnHover}
|
||||
badged={badged}
|
||||
contrast={contrast}
|
||||
prefetch={prefetch}
|
||||
icon={<IconHidden size={16} />}
|
||||
iconBadgeEnd={<IconHidden
|
||||
size={13}
|
||||
className="translate-y-[-0.5px]"
|
||||
/>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,34 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
||||
import { pathForTag } from '@/app/paths';
|
||||
import { formatTag } from '.';
|
||||
import EntityLink, {
|
||||
EntityLinkExternalProps,
|
||||
} from '@/components/primitives/EntityLink';
|
||||
} from '@/components/entity/EntityLink';
|
||||
import IconTag from '@/components/icons/IconTag';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoTag({
|
||||
tag,
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
tag: string
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={formatTag(tag)}
|
||||
path={pathForTag(tag)}
|
||||
tooltipImagePath={pathForTagImage(tag)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ tag }}
|
||||
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import PhotoTag from '@/tag/PhotoTag';
|
||||
import { isTagFavs } from '.';
|
||||
import FavsTag from './FavsTag';
|
||||
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
|
||||
import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export default function PhotoTags({
|
||||
|
||||
@ -28,12 +28,12 @@ export default async function TagHeader({
|
||||
entity={isTagFavs(tag)
|
||||
? <FavsTag
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>
|
||||
: <PhotoTag
|
||||
tag={tag}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityVerb={appText.category.taggedPhotos}
|
||||
entityDescription={descriptionForTaggedPhotos(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
|
||||
@ -1,33 +1,24 @@
|
||||
import { pathForYear, pathForYearImage } from '@/app/paths';
|
||||
import { pathForYear } from '@/app/paths';
|
||||
import EntityLink, { EntityLinkExternalProps } from
|
||||
'@/components/primitives/EntityLink';
|
||||
'@/components/entity/EntityLink';
|
||||
import IconYear from '@/components/icons/IconYear';
|
||||
import { useAppText } from '@/i18n/state/client';
|
||||
import { photoQuantityText } from '@/photo';
|
||||
|
||||
export default function PhotoYear({
|
||||
year,
|
||||
countOnHover,
|
||||
...props
|
||||
}: {
|
||||
year: string
|
||||
countOnHover?: number
|
||||
} & EntityLinkExternalProps) {
|
||||
const appText = useAppText();
|
||||
|
||||
return (
|
||||
<EntityLink
|
||||
{...props}
|
||||
label={year}
|
||||
path={pathForYear(year)}
|
||||
tooltipImagePath={pathForYearImage(year)}
|
||||
tooltipCaption={countOnHover &&
|
||||
photoQuantityText(countOnHover, appText, false)}
|
||||
hoverPhotoQueryOptions={{ year }}
|
||||
icon={<IconYear
|
||||
size={14}
|
||||
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
||||
/>}
|
||||
hoverEntity={countOnHover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -29,7 +29,7 @@ export default function YearHeader({
|
||||
entity={<PhotoYear
|
||||
year={year}
|
||||
contrast="high"
|
||||
showTooltip={false}
|
||||
showHover={false}
|
||||
/>}
|
||||
entityDescription={descriptionForPhotoSet(
|
||||
photos,
|
||||
|
||||
@ -42,6 +42,7 @@ html {
|
||||
--text-3xl--line-height: 1.5rem;
|
||||
|
||||
--animate-fade-in: fade-in 0.5s linear both running;
|
||||
--animate-fade-in-fast: fade-in 0.2s linear both running;
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user