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)
|
- `recipes` (default)
|
||||||
- `films` (default)
|
- `films` (default)
|
||||||
- `focal-lengths`
|
- `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_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
|
- `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
|
#### 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_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_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_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_TAKEN_AT_TIME = 1` hides taken at time from photo meta
|
||||||
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
|
- `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 DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
||||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
import AppGrid from '@/components/AppGrid';
|
import AppGrid from '@/components/AppGrid';
|
||||||
import EntityLink from '@/components/primitives/EntityLink';
|
import EntityLink from '@/components/entity/EntityLink';
|
||||||
import LabeledIcon from '@/components/primitives/LabeledIcon';
|
import LabeledIcon from '@/components/primitives/LabeledIcon';
|
||||||
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
|
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';
|
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';
|
||||||
|
|||||||
@ -10,11 +10,11 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
|||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
import { SortProps } from '@/photo/db/sort';
|
import { SortProps } from '@/photo/db/sort';
|
||||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||||
import { GetPhotosOptions } from '@/photo/db';
|
import { PhotoQueryOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||||
...options,
|
...options,
|
||||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -9,12 +9,12 @@ import { getPhotos } from '@/photo/db/query';
|
|||||||
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
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 dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||||
...options,
|
...options,
|
||||||
limit: INFINITE_SCROLL_FEED_INITIAL,
|
limit: INFINITE_SCROLL_FEED_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -11,11 +11,11 @@ import { getDataForCategoriesCached } from '@/category/cache';
|
|||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
import { SortProps } from '@/photo/db/sort';
|
import { SortProps } from '@/photo/db/sort';
|
||||||
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
|
||||||
import { GetPhotosOptions } from '@/photo/db';
|
import { PhotoQueryOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||||
...options,
|
...options,
|
||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
|
|||||||
import { getDataForCategoriesCached } from '@/category/cache';
|
import { getDataForCategoriesCached } from '@/category/cache';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
|
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 dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||||
...options,
|
...options,
|
||||||
limit: INFINITE_SCROLL_GRID_INITIAL,
|
limit: INFINITE_SCROLL_GRID_INITIAL,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
SITE_FEEDS_ENABLED,
|
SITE_FEEDS_ENABLED,
|
||||||
ADMIN_DEBUG_TOOLS_ENABLED,
|
ADMIN_DEBUG_TOOLS_ENABLED,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import AppStateProvider from '@/state/AppStateProvider';
|
import AppStateProvider from '@/app/AppStateProvider';
|
||||||
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
||||||
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
|
||||||
import { Metadata } from 'next/types';
|
import { Metadata } from 'next/types';
|
||||||
@ -21,7 +21,7 @@ import { ThemeProvider } from 'next-themes';
|
|||||||
import Nav from '@/app/Nav';
|
import Nav from '@/app/Nav';
|
||||||
import Footer from '@/app/Footer';
|
import Footer from '@/app/Footer';
|
||||||
import CommandK from '@/cmdk/CommandK';
|
import CommandK from '@/cmdk/CommandK';
|
||||||
import SwrConfigClient from '@/state/SwrConfigClient';
|
import SwrConfigClient from '@/swr/SwrConfigClient';
|
||||||
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
|
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
|
||||||
import ShareModals from '@/share/ShareModals';
|
import ShareModals from '@/share/ShareModals';
|
||||||
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
|
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
|
||||||
@ -29,7 +29,7 @@ import { revalidatePath } from 'next/cache';
|
|||||||
import RecipeModal from '@/recipe/RecipeModal';
|
import RecipeModal from '@/recipe/RecipeModal';
|
||||||
import ThemeColors from '@/app/ThemeColors';
|
import ThemeColors from '@/app/ThemeColors';
|
||||||
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
import AppTextProvider from '@/i18n/state/AppTextProvider';
|
||||||
import OGTooltipProvider from '@/components/og/OGTooltipProvider';
|
import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
|
||||||
|
|
||||||
import '../tailwind.css';
|
import '../tailwind.css';
|
||||||
|
|
||||||
@ -97,7 +97,7 @@ export default function RootLayout({
|
|||||||
<ThemeColors />
|
<ThemeColors />
|
||||||
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
|
||||||
<SwrConfigClient>
|
<SwrConfigClient>
|
||||||
<OGTooltipProvider>
|
<SharedHoverProvider>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'mx-3 mb-3',
|
'mx-3 mb-3',
|
||||||
'lg:mx-6 lg:mb-6',
|
'lg:mx-6 lg:mb-6',
|
||||||
@ -135,7 +135,7 @@ export default function RootLayout({
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
<CommandK />
|
<CommandK />
|
||||||
</OGTooltipProvider>
|
</SharedHoverProvider>
|
||||||
</SwrConfigClient>
|
</SwrConfigClient>
|
||||||
<Analytics debug={false} />
|
<Analytics debug={false} />
|
||||||
<SpeedInsights debug={false} />
|
<SpeedInsights debug={false} />
|
||||||
|
|||||||
@ -13,12 +13,12 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
|
|||||||
import PhotoGridPage from '@/photo/PhotoGridPage';
|
import PhotoGridPage from '@/photo/PhotoGridPage';
|
||||||
import { getDataForCategoriesCached } from '@/category/cache';
|
import { getDataForCategoriesCached } from '@/category/cache';
|
||||||
import { getPhotosMetaCached } from '@/photo/cache';
|
import { getPhotosMetaCached } from '@/photo/cache';
|
||||||
import { GetPhotosOptions } from '@/photo/db';
|
import { PhotoQueryOptions } from '@/photo/db';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
|
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
|
||||||
...options,
|
...options,
|
||||||
limit: GRID_HOMEPAGE_ENABLED
|
limit: GRID_HOMEPAGE_ENABLED
|
||||||
? INFINITE_SCROLL_GRID_INITIAL
|
? INFINITE_SCROLL_GRID_INITIAL
|
||||||
|
|||||||
22
package.json
22
package.json
@ -10,8 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^1.3.22",
|
"@ai-sdk/openai": "^1.3.22",
|
||||||
"@aws-sdk/client-s3": "3.840.0",
|
"@aws-sdk/client-s3": "3.842.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.840.0",
|
"@aws-sdk/s3-request-presigner": "3.842.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
@ -28,9 +28,9 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"framer-motion": "^12.22.0",
|
"framer-motion": "^12.23.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "15.3.4",
|
"next": "15.3.5",
|
||||||
"next-auth": "5.0.0-beta.28",
|
"next-auth": "5.0.0-beta.28",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
@ -39,16 +39,16 @@
|
|||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.6",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.4",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"use-debounce": "^10.0.5",
|
"use-debounce": "^10.0.5",
|
||||||
"viewerjs": "^1.11.7"
|
"viewerjs": "^1.11.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@next/bundle-analyzer": "15.3.4",
|
"@next/bundle-analyzer": "15.3.5",
|
||||||
"@next/eslint-plugin-next": "^15.3.4",
|
"@next/eslint-plugin-next": "^15.3.5",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
@ -63,10 +63,10 @@
|
|||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"eslint": "9.30.1",
|
"eslint": "9.30.1",
|
||||||
"eslint-config-next": "15.3.4",
|
"eslint-config-next": "15.3.5",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"jest": "^30.0.3",
|
"jest": "^30.0.4",
|
||||||
"jest-environment-jsdom": "^30.0.2",
|
"jest-environment-jsdom": "^30.0.4",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"ts-node": "^10.9.2",
|
"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,
|
imageQuality,
|
||||||
isBlurEnabled,
|
isBlurEnabled,
|
||||||
// Categories
|
// Categories
|
||||||
categoryVisibility,
|
|
||||||
hasCategoryVisibility,
|
hasCategoryVisibility,
|
||||||
|
categoryVisibility,
|
||||||
|
showCategoryImageHover,
|
||||||
collapseSidebarCategories,
|
collapseSidebarCategories,
|
||||||
hideTagsWithOnePhoto,
|
hideTagsWithOnePhoto,
|
||||||
// Sort
|
// Sort
|
||||||
@ -94,7 +95,6 @@ export default function AdminAppConfigurationClient({
|
|||||||
// Display
|
// Display
|
||||||
showKeyboardShortcutTooltips,
|
showKeyboardShortcutTooltips,
|
||||||
showExifInfo,
|
showExifInfo,
|
||||||
showCategoryImageHover,
|
|
||||||
showZoomControls,
|
showZoomControls,
|
||||||
showTakenAtTimeHidden,
|
showTakenAtTimeHidden,
|
||||||
showSocial,
|
showSocial,
|
||||||
@ -582,6 +582,19 @@ export default function AdminAppConfigurationClient({
|
|||||||
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
|
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
|
||||||
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Collapsible sidebar"
|
title="Collapsible sidebar"
|
||||||
status={collapseSidebarCategories}
|
status={collapseSidebarCategories}
|
||||||
@ -667,25 +680,6 @@ export default function AdminAppConfigurationClient({
|
|||||||
Set environment variable to {'"1"'} to hide EXIF data:
|
Set environment variable to {'"1"'} to hide EXIF data:
|
||||||
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
|
||||||
</ChecklistRow>
|
</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
|
<ChecklistRow
|
||||||
title="Show zoom controls"
|
title="Show zoom controls"
|
||||||
status={showZoomControls}
|
status={showZoomControls}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { LuCog } from 'react-icons/lu';
|
import { LuCog } from 'react-icons/lu';
|
||||||
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
PATH_ADMIN_UPLOADS,
|
PATH_ADMIN_UPLOADS,
|
||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5';
|
import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import AdminAppInfoIcon from './AdminAppInfoIcon';
|
import AdminAppInfoIcon from './AdminAppInfoIcon';
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import Note from '@/components/Note';
|
import Note from '@/components/Note';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import AppGrid from '@/components/AppGrid';
|
import AppGrid from '@/components/AppGrid';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { IoCloseSharp } from 'react-icons/io5';
|
import { IoCloseSharp } from 'react-icons/io5';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import ProgressButton from '@/components/primitives/ProgressButton';
|
|||||||
import { UrlAddStatus } from './AdminUploadsClient';
|
import { UrlAddStatus } from './AdminUploadsClient';
|
||||||
import PhotoTagFieldset from './PhotoTagFieldset';
|
import PhotoTagFieldset from './PhotoTagFieldset';
|
||||||
import DeleteUploadButton from './DeleteUploadButton';
|
import DeleteUploadButton from './DeleteUploadButton';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { pluralize } from '@/utility/string';
|
import { pluralize } from '@/utility/string';
|
||||||
import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
import FieldsetFavs from '@/photo/form/FieldsetFavs';
|
||||||
import FieldsetHidden from '@/photo/form/FieldsetHidden';
|
import FieldsetHidden from '@/photo/form/FieldsetHidden';
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import ResponsiveText from '@/components/primitives/ResponsiveText';
|
|||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import ClearCacheButton from '@/admin/ClearCacheButton';
|
import ClearCacheButton from '@/admin/ClearCacheButton';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
|
||||||
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
|
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
isPathAdminInfo,
|
isPathAdminInfo,
|
||||||
isPathTopLevelAdmin,
|
isPathTopLevelAdmin,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { differenceInMinutes } from 'date-fns';
|
import { differenceInMinutes } from 'date-fns';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { isPathFavs, isPhotoFav, TAG_HIDDEN } from '@/tag';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { BiTrash } from 'react-icons/bi';
|
import { BiTrash } from 'react-icons/bi';
|
||||||
import MoreMenu from '@/components/more/MoreMenu';
|
import MoreMenu from '@/components/more/MoreMenu';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||||
import { MdOutlineFileDownload } from 'react-icons/md';
|
import { MdOutlineFileDownload } from 'react-icons/md';
|
||||||
import MoreMenuItem from '@/components/more/MoreMenuItem';
|
import MoreMenuItem from '@/components/more/MoreMenuItem';
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Photo } from '@/photo';
|
|||||||
import { StorageListResponse } from '@/platforms/storage';
|
import { StorageListResponse } from '@/platforms/storage';
|
||||||
import AdminUploadsTable from './AdminUploadsTable';
|
import AdminUploadsTable from './AdminUploadsTable';
|
||||||
import { Timezone } from '@/utility/timezone';
|
import { Timezone } from '@/utility/timezone';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||||
import { pluralize } from '@/utility/string';
|
import { pluralize } from '@/utility/string';
|
||||||
import IconBroom from '@/components/icons/IconBroom';
|
import IconBroom from '@/components/icons/IconBroom';
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PhotoDate from '@/photo/PhotoDate';
|
import PhotoDate from '@/photo/PhotoDate';
|
||||||
import EditButton from './EditButton';
|
import EditButton from './EditButton';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||||
import PhotoSyncButton from './PhotoSyncButton';
|
import PhotoSyncButton from './PhotoSyncButton';
|
||||||
import DeletePhotoButton from './DeletePhotoButton';
|
import DeletePhotoButton from './DeletePhotoButton';
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
|||||||
import { ReactNode, useMemo, useState } from 'react';
|
import { ReactNode, useMemo, useState } from 'react';
|
||||||
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
|
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
|
||||||
import { parameterize } from '@/utility/string';
|
import { parameterize } from '@/utility/string';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
|
|
||||||
export default function AdminRecipeForm({
|
export default function AdminRecipeForm({
|
||||||
recipe,
|
recipe,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import { RecipeProps } from '@/recipe';
|
import { RecipeProps } from '@/recipe';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { TbChecklist } from 'react-icons/tb';
|
import { TbChecklist } from 'react-icons/tb';
|
||||||
|
|
||||||
export default function AdminShowRecipeButton(props: RecipeProps) {
|
export default function AdminShowRecipeButton(props: RecipeProps) {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
|||||||
import { ReactNode, useMemo, useState } from 'react';
|
import { ReactNode, useMemo, useState } from 'react';
|
||||||
import { renamePhotoTagGloballyAction } from '@/photo/actions';
|
import { renamePhotoTagGloballyAction } from '@/photo/actions';
|
||||||
import { parameterize } from '@/utility/string';
|
import { parameterize } from '@/utility/string';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
|
|
||||||
export default function AdminTagForm({
|
export default function AdminTagForm({
|
||||||
tag,
|
tag,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
import { clearCacheAction } from '@/photo/actions';
|
import { clearCacheAction } from '@/photo/actions';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { BiTrash } from 'react-icons/bi';
|
import { BiTrash } from 'react-icons/bi';
|
||||||
|
|
||||||
export default function ClearCacheButton() {
|
export default function ClearCacheButton() {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { ComponentProps, useCallback } from 'react';
|
import { ComponentProps, useCallback } from 'react';
|
||||||
import { BiTrash } from 'react-icons/bi';
|
import { BiTrash } from 'react-icons/bi';
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import { photoQuantityText } from '@/photo';
|
import { photoQuantityText } from '@/photo';
|
||||||
import { deletePhotosAction } from '@/photo/actions';
|
import { deletePhotosAction } from '@/photo/actions';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { toastSuccess, toastWarning } from '@/toast';
|
import { toastSuccess, toastWarning } from '@/toast';
|
||||||
import { ComponentProps, useState } from 'react';
|
import { ComponentProps, useState } from 'react';
|
||||||
import DeleteButton from './DeleteButton';
|
import DeleteButton from './DeleteButton';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import SignInForm from '@/auth/SignInForm';
|
import SignInForm from '@/auth/SignInForm';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||||
|
|||||||
@ -36,7 +36,7 @@ import AdminLink from '../AdminLink';
|
|||||||
import AdminEmptyState from '../AdminEmptyState';
|
import AdminEmptyState from '../AdminEmptyState';
|
||||||
import { pluralize } from '@/utility/string';
|
import { pluralize } from '@/utility/string';
|
||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import ScoreCardContainer from '@/components/ScoreCardContainer';
|
import ScoreCardContainer from '@/components/ScoreCardContainer';
|
||||||
import IconLens from '@/components/icons/IconLens';
|
import IconLens from '@/components/icons/IconLens';
|
||||||
import IconCamera from '@/components/icons/IconCamera';
|
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 clsx from 'clsx/lite';
|
||||||
import { FaCircle } from 'react-icons/fa6';
|
import { FaCircle } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Container from '@/components/Container';
|
|||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import AppGrid from '@/components/AppGrid';
|
import AppGrid from '@/components/AppGrid';
|
||||||
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { IoCloseSharp } from 'react-icons/io5';
|
import { IoCloseSharp } from 'react-icons/io5';
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ export type AppStateContextType = {
|
|||||||
previousPathname?: string
|
previousPathname?: string
|
||||||
hasLoaded?: boolean
|
hasLoaded?: boolean
|
||||||
hasLoadedWithAnimations?: boolean
|
hasLoadedWithAnimations?: boolean
|
||||||
swrTimestamp?: number
|
|
||||||
invalidateSwr?: () => void
|
invalidateSwr?: () => void
|
||||||
nextPhotoAnimation?: AnimationConfig
|
nextPhotoAnimation?: AnimationConfig
|
||||||
setNextPhotoAnimation?: (animationConfig?: AnimationConfig) => void
|
setNextPhotoAnimation?: (animationConfig?: AnimationConfig) => void
|
||||||
@ -7,11 +7,11 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { AppStateContext } from './AppState';
|
import { AppStateContext } from '../app/AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import usePathnames from '@/utility/usePathnames';
|
import usePathnames from '@/utility/usePathnames';
|
||||||
import { getAuthAction } from '@/auth/actions';
|
import { getAuthAction } from '@/auth/actions';
|
||||||
import useSWR from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
import {
|
import {
|
||||||
HIGH_DENSITY_GRID,
|
HIGH_DENSITY_GRID,
|
||||||
IS_DEVELOPMENT,
|
IS_DEVELOPMENT,
|
||||||
@ -30,9 +30,16 @@ import { useRouter, usePathname } from 'next/navigation';
|
|||||||
import { isPathProtected, PATH_ROOT } from '@/app/paths';
|
import { isPathProtected, PATH_ROOT } from '@/app/paths';
|
||||||
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
|
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
|
||||||
import { RecipeProps } from '@/recipe';
|
import { RecipeProps } from '@/recipe';
|
||||||
import { getCountsForCategoriesCachedAction } from '@/category/actions';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { toastSuccess } from '@/toast';
|
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({
|
export default function AppStateProvider({
|
||||||
children,
|
children,
|
||||||
@ -52,8 +59,6 @@ export default function AppStateProvider({
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
|
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [swrTimestamp, setSwrTimestamp] =
|
|
||||||
useState(Date.now());
|
|
||||||
const [nextPhotoAnimation, _setNextPhotoAnimation] =
|
const [nextPhotoAnimation, _setNextPhotoAnimation] =
|
||||||
useState<AnimationConfig>();
|
useState<AnimationConfig>();
|
||||||
const setNextPhotoAnimation = useCallback((animation?: AnimationConfig) => {
|
const setNextPhotoAnimation = useCallback((animation?: AnimationConfig) => {
|
||||||
@ -123,10 +128,14 @@ export default function AppStateProvider({
|
|||||||
return () => clearTimeout(timeout);
|
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(
|
const { data: categoriesWithCounts } = useSWR(
|
||||||
'getDataForCategories',
|
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
|
||||||
getCountsForCategoriesCachedAction,
|
getCountsForCategoriesCachedAction,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -134,7 +143,7 @@ export default function AppStateProvider({
|
|||||||
data: auth,
|
data: auth,
|
||||||
error: authError,
|
error: authError,
|
||||||
isLoading: isCheckingAuth,
|
isLoading: isCheckingAuth,
|
||||||
} = useSWR('getAuth', getAuthAction);
|
} = useSWR(SWR_KEY_GET_AUTH, getAuthAction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth === null || authError) {
|
if (auth === null || authError) {
|
||||||
setUserEmail(undefined);
|
setUserEmail(undefined);
|
||||||
@ -152,7 +161,7 @@ export default function AppStateProvider({
|
|||||||
mutate: refreshAdminData,
|
mutate: refreshAdminData,
|
||||||
isLoading: isLoadingAdminData,
|
isLoading: isLoadingAdminData,
|
||||||
} = useSWR(
|
} = useSWR(
|
||||||
isUserSignedIn ? 'getAdminData' : null,
|
isUserSignedIn ? SWR_KEY_GET_ADMIN_DATA : null,
|
||||||
getAdminDataAction,
|
getAdminDataAction,
|
||||||
);
|
);
|
||||||
const updateAdminData = useCallback(
|
const updateAdminData = useCallback(
|
||||||
@ -212,7 +221,6 @@ export default function AppStateProvider({
|
|||||||
previousPathname,
|
previousPathname,
|
||||||
hasLoaded,
|
hasLoaded,
|
||||||
hasLoadedWithAnimations,
|
hasLoadedWithAnimations,
|
||||||
swrTimestamp,
|
|
||||||
invalidateSwr,
|
invalidateSwr,
|
||||||
nextPhotoAnimation,
|
nextPhotoAnimation,
|
||||||
setNextPhotoAnimation,
|
setNextPhotoAnimation,
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
PATH_GRID_INFERRED,
|
PATH_GRID_INFERRED,
|
||||||
} from '@/app/paths';
|
} from '@/app/paths';
|
||||||
import IconSearch from '../components/icons/IconSearch';
|
import IconSearch from '../components/icons/IconSearch';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import {
|
import {
|
||||||
GRID_HOMEPAGE_ENABLED,
|
GRID_HOMEPAGE_ENABLED,
|
||||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
|
|||||||
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
|
||||||
import { signOutAction } from '@/auth/actions';
|
import { signOutAction } from '@/auth/actions';
|
||||||
import AnimateItems from '@/components/AnimateItems';
|
import AnimateItems from '@/components/AnimateItems';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import {
|
|||||||
} from './config';
|
} from './config';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import useStickyNav from './useStickyNav';
|
import useStickyNav from './useStickyNav';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
|
|
||||||
const NAV_HEIGHT_CLASS = NAV_CAPTION
|
const NAV_HEIGHT_CLASS = NAV_CAPTION
|
||||||
? 'min-h-[4rem] sm:min-h-[5rem]'
|
? 'min-h-[4rem] sm:min-h-[5rem]'
|
||||||
|
|||||||
@ -263,6 +263,8 @@ export const SHOW_FILMS =
|
|||||||
CATEGORY_VISIBILITY.includes('films');
|
CATEGORY_VISIBILITY.includes('films');
|
||||||
export const SHOW_FOCAL_LENGTHS =
|
export const SHOW_FOCAL_LENGTHS =
|
||||||
CATEGORY_VISIBILITY.includes('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 =
|
export const COLLAPSE_SIDEBAR_CATEGORIES =
|
||||||
process.env.NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES !== '1';
|
process.env.NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES !== '1';
|
||||||
export const HIDE_TAGS_WITH_ONE_PHOTO =
|
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';
|
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
|
||||||
export const SHOW_EXIF_DATA =
|
export const SHOW_EXIF_DATA =
|
||||||
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
|
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 =
|
export const SHOW_ZOOM_CONTROLS =
|
||||||
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
|
||||||
export const SHOW_TAKEN_AT_TIME =
|
export const SHOW_TAKEN_AT_TIME =
|
||||||
@ -415,6 +415,7 @@ export const APP_CONFIGURATION = {
|
|||||||
hasCategoryVisibility:
|
hasCategoryVisibility:
|
||||||
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
|
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
|
||||||
categoryVisibility: CATEGORY_VISIBILITY,
|
categoryVisibility: CATEGORY_VISIBILITY,
|
||||||
|
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
||||||
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
|
||||||
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
|
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
|
||||||
// Sort
|
// Sort
|
||||||
@ -425,7 +426,6 @@ export const APP_CONFIGURATION = {
|
|||||||
// Display
|
// Display
|
||||||
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||||
showExifInfo: SHOW_EXIF_DATA,
|
showExifInfo: SHOW_EXIF_DATA,
|
||||||
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
|
|
||||||
showZoomControls: SHOW_ZOOM_CONTROLS,
|
showZoomControls: SHOW_ZOOM_CONTROLS,
|
||||||
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
|
||||||
showSocial: SHOW_SOCIAL,
|
showSocial: SHOW_SOCIAL,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
KEY_CREDENTIALS_SUCCESS,
|
KEY_CREDENTIALS_SUCCESS,
|
||||||
} from '.';
|
} from '.';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
|
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
|
||||||
import IconLock from '@/components/icons/IconLock';
|
import IconLock from '@/components/icons/IconLock';
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export default async function CameraHeader({
|
|||||||
entity={<PhotoCamera
|
entity={<PhotoCamera
|
||||||
{...{ camera }}
|
{...{ camera }}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={
|
entityDescription={
|
||||||
descriptionForCameraPhotos(
|
descriptionForCameraPhotos(
|
||||||
|
|||||||
@ -1,27 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { AiFillApple } from 'react-icons/ai';
|
import { AiFillApple } from 'react-icons/ai';
|
||||||
import { pathForCamera, pathForCameraImage } from '@/app/paths';
|
import { pathForCamera } from '@/app/paths';
|
||||||
import { Camera, formatCameraText } from '.';
|
import { Camera, formatCameraText } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import IconCamera from '@/components/icons/IconCamera';
|
import IconCamera from '@/components/icons/IconCamera';
|
||||||
import { isCameraApple } from '@/platforms/apple';
|
import { isCameraApple } from '@/platforms/apple';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoCamera({
|
export default function PhotoCamera({
|
||||||
camera,
|
camera,
|
||||||
hideAppleIcon,
|
hideAppleIcon,
|
||||||
countOnHover,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
camera: Camera
|
camera: Camera
|
||||||
hideAppleIcon?: boolean
|
hideAppleIcon?: boolean
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
const isApple = isCameraApple(camera);
|
const isApple = isCameraApple(camera);
|
||||||
const showAppleIcon = !hideAppleIcon && isApple;
|
const showAppleIcon = !hideAppleIcon && isApple;
|
||||||
|
|
||||||
@ -30,9 +25,7 @@ export default function PhotoCamera({
|
|||||||
{...props}
|
{...props}
|
||||||
label={formatCameraText(camera)}
|
label={formatCameraText(camera)}
|
||||||
path={pathForCamera(camera)}
|
path={pathForCamera(camera)}
|
||||||
tooltipImagePath={pathForCameraImage(camera)}
|
hoverPhotoQueryOptions={{ camera }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={showAppleIcon
|
icon={showAppleIcon
|
||||||
? <AiFillApple
|
? <AiFillApple
|
||||||
title="Apple"
|
title="Apple"
|
||||||
@ -43,7 +36,6 @@ export default function PhotoCamera({
|
|||||||
size={15}
|
size={15}
|
||||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||||
/>}
|
/>}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { createCameraKey, Camera } from '@/camera';
|
import { createCameraKey, Camera } from '@/camera';
|
||||||
import { createLensKey, Lens } from '@/lens';
|
import { createLensKey, Lens } from '@/lens';
|
||||||
import { useAppState } from '@/state/AppState';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
|
import { useAppState } from '@/app/AppState';
|
||||||
|
|
||||||
export default function useCategoryCounts() {
|
export default function useCategoryCounts() {
|
||||||
const { categoriesWithCounts } = useAppState();
|
const { categoriesWithCounts } = useAppState();
|
||||||
|
|||||||
@ -41,7 +41,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
|
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
|
||||||
import { IoInvertModeSharp } from 'react-icons/io5';
|
import { IoInvertModeSharp } from 'react-icons/io5';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { searchPhotosAction } from '@/photo/actions';
|
import { searchPhotosAction } from '@/photo/actions';
|
||||||
import { RiToolsFill } from 'react-icons/ri';
|
import { RiToolsFill } from 'react-icons/ri';
|
||||||
import { signOutAction } from '@/auth/actions';
|
import { signOutAction } from '@/auth/actions';
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ReactNode, useRef } from 'react';
|
import { ReactNode, useRef } from 'react';
|
||||||
import { Variant, motion } from 'framer-motion';
|
import { Variant, motion } from 'framer-motion';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||||
|
|
||||||
const IGNORE_CAN_START = true;
|
const IGNORE_CAN_START = true;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
|
|||||||
import { FiUploadCloud } from 'react-icons/fi';
|
import { FiUploadCloud } from 'react-icons/fi';
|
||||||
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
|
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
|
||||||
import ProgressButton from './primitives/ProgressButton';
|
import ProgressButton from './primitives/ProgressButton';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
export default function ImageInput({
|
export default function ImageInput({
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import SimpleCheckbox from './primitives/SimpleCheckbox';
|
import SimpleCheckbox from './primitives/SimpleCheckbox';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
|
|
||||||
export default function SelectTileOverlay({
|
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';
|
'use client';
|
||||||
|
|
||||||
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
|
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
|
||||||
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
|
import LabeledIcon, { LabeledIconType } from '../primitives/LabeledIcon';
|
||||||
import Badge from '../Badge';
|
import Badge from '../Badge';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import LinkWithStatus from '../LinkWithStatus';
|
import LinkWithStatus from '../LinkWithStatus';
|
||||||
import Spinner from '../Spinner';
|
import Spinner from '../Spinner';
|
||||||
import ResponsiveText from './ResponsiveText';
|
import ResponsiveText from '../primitives/ResponsiveText';
|
||||||
import OGTooltip from '../og/OGTooltip';
|
|
||||||
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
|
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 {
|
export interface EntityLinkExternalProps {
|
||||||
ref?: RefObject<HTMLSpanElement | null>
|
ref?: RefObject<HTMLSpanElement | null>
|
||||||
type?: LabeledIconType
|
type?: LabeledIconType
|
||||||
badged?: boolean
|
badged?: boolean
|
||||||
contrast?: ComponentProps<typeof Badge>['contrast']
|
contrast?: ComponentProps<typeof Badge>['contrast']
|
||||||
showTooltip?: boolean
|
|
||||||
uppercase?: boolean
|
uppercase?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
suppressSpinner?: boolean
|
suppressSpinner?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
countOnHover?: number
|
||||||
|
showHover?: boolean
|
||||||
|
hoverPhotoQueryOptions?: PhotoQueryOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntityLink({
|
export default function EntityLink({
|
||||||
ref,
|
ref,
|
||||||
icon,
|
icon,
|
||||||
iconBadge,
|
iconBadgeStart,
|
||||||
|
iconBadgeEnd,
|
||||||
label,
|
label,
|
||||||
labelSmall,
|
labelSmall,
|
||||||
labelComplex,
|
|
||||||
iconWide,
|
iconWide,
|
||||||
type,
|
type,
|
||||||
badged,
|
badged,
|
||||||
contrast = 'medium',
|
contrast = 'medium',
|
||||||
showTooltip = SHOW_CATEGORY_IMAGE_HOVERS,
|
|
||||||
path = '', // Make link optional for debugging purposes
|
path = '', // Make link optional for debugging purposes
|
||||||
tooltipImagePath,
|
showHover = SHOW_CATEGORY_IMAGE_HOVERS,
|
||||||
tooltipCaption,
|
countOnHover,
|
||||||
|
hoverPhotoQueryOptions,
|
||||||
prefetch,
|
prefetch,
|
||||||
title,
|
title,
|
||||||
action,
|
action,
|
||||||
hoverEntity,
|
|
||||||
truncate = true,
|
truncate = true,
|
||||||
className,
|
className,
|
||||||
classNameIcon,
|
classNameIcon,
|
||||||
@ -49,18 +53,15 @@ export default function EntityLink({
|
|||||||
debug,
|
debug,
|
||||||
}: {
|
}: {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
iconBadge?: ReactNode
|
iconBadgeStart?: ReactNode
|
||||||
|
iconBadgeEnd?: ReactNode
|
||||||
label: string
|
label: string
|
||||||
labelSmall?: ReactNode
|
labelSmall?: ReactNode
|
||||||
labelComplex?: ReactNode
|
|
||||||
iconWide?: boolean
|
iconWide?: boolean
|
||||||
path?: string
|
path?: string
|
||||||
tooltipImagePath?: string
|
|
||||||
tooltipCaption?: ReactNode
|
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
hoverEntity?: ReactNode
|
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
classNameIcon?: string
|
classNameIcon?: string
|
||||||
@ -69,6 +70,8 @@ export default function EntityLink({
|
|||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const hasBadgeIcon = Boolean(iconBadgeStart || iconBadgeEnd);
|
||||||
|
|
||||||
const classForContrast = () => {
|
const classForContrast = () => {
|
||||||
switch (contrast) {
|
switch (contrast) {
|
||||||
case 'low':
|
case 'low':
|
||||||
@ -84,15 +87,15 @@ export default function EntityLink({
|
|||||||
|
|
||||||
const showHoverEntity =
|
const showHoverEntity =
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
hoverEntity !== undefined &&
|
countOnHover &&
|
||||||
!showTooltip;
|
!showHover;
|
||||||
|
|
||||||
const renderLabel =
|
const renderLabel =
|
||||||
<ResponsiveText shortText={labelSmall}>
|
<ResponsiveText shortText={labelSmall}>
|
||||||
{labelComplex || label}
|
{label}
|
||||||
</ResponsiveText>;
|
</ResponsiveText>;
|
||||||
|
|
||||||
const renderLink =
|
const renderLink = (useForHover?: boolean) =>
|
||||||
<LinkWithStatus
|
<LinkWithStatus
|
||||||
href={path}
|
href={path}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -107,28 +110,33 @@ export default function EntityLink({
|
|||||||
setIsLoading={setIsLoading}
|
setIsLoading={setIsLoading}
|
||||||
>
|
>
|
||||||
<LabeledIcon {...{
|
<LabeledIcon {...{
|
||||||
icon,
|
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
|
||||||
iconWide,
|
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
|
||||||
prefetch,
|
prefetch,
|
||||||
title,
|
title,
|
||||||
type,
|
type: useForHover ? 'icon-first' : type,
|
||||||
uppercase,
|
uppercase,
|
||||||
classNameIcon: clsx('text-dim', classNameIcon),
|
className: useForHover ? 'text-white' : undefined,
|
||||||
|
classNameIcon: clsx(
|
||||||
|
!useForHover && 'text-dim',
|
||||||
|
classNameIcon,
|
||||||
|
),
|
||||||
debug,
|
debug,
|
||||||
}}>
|
}}>
|
||||||
{badged
|
{badged && !useForHover
|
||||||
? <Badge
|
? <Badge
|
||||||
type="small"
|
type="small"
|
||||||
contrast={contrast}
|
contrast={contrast}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'translate-y-[-0.5px]',
|
'translate-y-[-0.5px]',
|
||||||
iconBadge && '*:flex *:items-center *:gap-1',
|
hasBadgeIcon && '*:flex *:items-center *:gap-1',
|
||||||
)}
|
)}
|
||||||
uppercase
|
uppercase
|
||||||
interactive
|
interactive
|
||||||
>
|
>
|
||||||
{iconBadge}
|
{iconBadgeStart}
|
||||||
{renderLabel}
|
{renderLabel}
|
||||||
|
{iconBadgeEnd}
|
||||||
</Badge>
|
</Badge>
|
||||||
: <span className={clsx(
|
: <span className={clsx(
|
||||||
'text-content',
|
'text-content',
|
||||||
@ -152,23 +160,28 @@ export default function EntityLink({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showTooltip && tooltipImagePath
|
{showHover && countOnHover && hoverPhotoQueryOptions
|
||||||
? <OGTooltip
|
? <EntityHover
|
||||||
title={label}
|
hoverKey={path}
|
||||||
path={tooltipImagePath}
|
header={renderLink(true)}
|
||||||
caption={tooltipCaption}
|
photosCount={countOnHover}
|
||||||
|
getPhotos={() =>
|
||||||
|
getPhotosCachedAction({
|
||||||
|
...hoverPhotoQueryOptions,
|
||||||
|
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
|
||||||
|
})}
|
||||||
color={contrast === 'frosted' ? 'frosted' : undefined}
|
color={contrast === 'frosted' ? 'frosted' : undefined}
|
||||||
>
|
>
|
||||||
{renderLink}
|
{renderLink()}
|
||||||
</OGTooltip>
|
</EntityHover>
|
||||||
: renderLink}
|
: renderLink()}
|
||||||
{action &&
|
{action &&
|
||||||
<span className="action">
|
<span className="action">
|
||||||
{action}
|
{action}
|
||||||
</span>}
|
</span>}
|
||||||
{showHoverEntity &&
|
{showHoverEntity &&
|
||||||
<span className="hidden peer-hover:inline text-dim">
|
<span className="hidden peer-hover:inline text-dim">
|
||||||
{hoverEntity}
|
{countOnHover}
|
||||||
</span>}
|
</span>}
|
||||||
{isLoading && !suppressSpinner &&
|
{isLoading && !suppressSpinner &&
|
||||||
<Spinner
|
<Spinner
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/* eslint-disable jsx-a11y/alt-text */
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
import { BLUR_ENABLED } from '@/app/config';
|
import { BLUR_ENABLED } from '@/app/config';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { clsx} from 'clsx/lite';
|
import { clsx} from 'clsx/lite';
|
||||||
import Image, { ImageProps } from 'next/image';
|
import Image, { ImageProps } from 'next/image';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
export default function ImageWithFallback({
|
export default function ImageWithFallback({
|
||||||
className,
|
className,
|
||||||
classNameImage = 'object-cover h-full',
|
classNameImage = 'object-cover h-full',
|
||||||
|
forceFallbackFade = false,
|
||||||
blurDataURL,
|
blurDataURL,
|
||||||
blurCompatibilityLevel = 'low',
|
blurCompatibilityLevel = 'low',
|
||||||
priority,
|
priority,
|
||||||
@ -17,12 +18,14 @@ export default function ImageWithFallback({
|
|||||||
}: ImageProps & {
|
}: ImageProps & {
|
||||||
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
||||||
classNameImage?: string
|
classNameImage?: string
|
||||||
|
forceFallbackFade?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { shouldDebugImageFallbacks } = useAppState();
|
const { shouldDebugImageFallbacks } = useAppState();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [didError, setDidError] = useState(false);
|
const [didError, setDidError] = useState(false);
|
||||||
const [fadeFallbackTransition, setFadeFallbackTransition] = useState(false);
|
const [fadeFallbackTransition, setFadeFallbackTransition] =
|
||||||
|
useState(forceFallbackFade);
|
||||||
|
|
||||||
const onLoad = useCallback(() => setIsLoading(false), []);
|
const onLoad = useCallback(() => setIsLoading(false), []);
|
||||||
const onError = useCallback(() => setDidError(true), []);
|
const onError = useCallback(() => setDidError(true), []);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import useMetaThemeColor from '@/utility/useMetaThemeColor';
|
import useMetaThemeColor from '@/utility/useMetaThemeColor';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import {
|
import {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
RefObject,
|
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 PhotoHeader from '@/photo/PhotoHeader';
|
||||||
import PhotoFilm from '@/film/PhotoFilm';
|
import PhotoFilm from '@/film/PhotoFilm';
|
||||||
import { getRecipePropsFromPhotos } from '@/recipe';
|
import { getRecipePropsFromPhotos } from '@/recipe';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ export default function FilmHeader({
|
|||||||
toggleRecipeOverlay={recipeProps
|
toggleRecipeOverlay={recipeProps
|
||||||
? () => setRecipeModalProps?.(recipeProps)
|
? () => setRecipeModalProps?.(recipeProps)
|
||||||
: undefined}
|
: undefined}
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={descriptionForFilmPhotos(
|
entityDescription={descriptionForFilmPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -1,33 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import PhotoFilmIcon from './PhotoFilmIcon';
|
import PhotoFilmIcon from './PhotoFilmIcon';
|
||||||
import { pathForFilm, pathForFilmImage } from '@/app/paths';
|
import { pathForFilm } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { labelForFilm } from '.';
|
import { labelForFilm } from '.';
|
||||||
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoFilm({
|
export default function PhotoFilm({
|
||||||
film,
|
film,
|
||||||
type = 'icon-last',
|
type = 'icon-last',
|
||||||
badged = true,
|
badged = true,
|
||||||
contrast = 'low',
|
contrast = 'low',
|
||||||
countOnHover,
|
|
||||||
toggleRecipeOverlay,
|
toggleRecipeOverlay,
|
||||||
isShowingRecipeOverlay,
|
isShowingRecipeOverlay,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
film: string
|
film: string
|
||||||
countOnHover?: number
|
|
||||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||||
& EntityLinkExternalProps) {
|
& EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
const { small, medium, large } = labelForFilm(film);
|
const { small, medium, large } = labelForFilm(film);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -36,9 +31,7 @@ export default function PhotoFilm({
|
|||||||
label={medium}
|
label={medium}
|
||||||
labelSmall={small}
|
labelSmall={small}
|
||||||
path={pathForFilm(film)}
|
path={pathForFilm(film)}
|
||||||
tooltipImagePath={pathForFilmImage(film)}
|
hoverPhotoQueryOptions={{ film }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<PhotoFilmIcon
|
icon={<PhotoFilmIcon
|
||||||
film={film}
|
film={film}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -57,7 +50,6 @@ export default function PhotoFilm({
|
|||||||
toggleRecipeOverlay,
|
toggleRecipeOverlay,
|
||||||
isShowingRecipeOverlay,
|
isShowingRecipeOverlay,
|
||||||
}} />}
|
}} />}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
iconWide={isStringFujifilmSimulation(film)}
|
iconWide={isStringFujifilmSimulation(film)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export default async function FocalLengthHeader({
|
|||||||
entity={<PhotoFocalLength
|
entity={<PhotoFocalLength
|
||||||
focal={focal}
|
focal={focal}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={descriptionForFocalLengthPhotos(
|
entityDescription={descriptionForFocalLengthPhotos(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -1,34 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
|
import { pathForFocalLength } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import { formatFocalLength } from '.';
|
import { formatFocalLength } from '.';
|
||||||
import IconFocalLength from '@/components/icons/IconFocalLength';
|
import IconFocalLength from '@/components/icons/IconFocalLength';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoFocalLength({
|
export default function PhotoFocalLength({
|
||||||
focal,
|
focal,
|
||||||
countOnHover,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
focal: number
|
focal: number
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatFocalLength(focal)}
|
label={formatFocalLength(focal)}
|
||||||
path={pathForFocalLength(focal)}
|
path={pathForFocalLength(focal)}
|
||||||
tooltipImagePath={pathForFocalLengthImage(focal)}
|
hoverPhotoQueryOptions={{ focal }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
icon={<IconFocalLength className="translate-y-[-1px]" />}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export default async function LensHeader({
|
|||||||
entity={<PhotoLens
|
entity={<PhotoLens
|
||||||
{...{ lens }}
|
{...{ lens }}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={
|
entityDescription={
|
||||||
descriptionForLensPhotos(
|
descriptionForLensPhotos(
|
||||||
|
|||||||
@ -1,39 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { pathForLens, pathForLensImage } from '@/app/paths';
|
import { pathForLens } from '@/app/paths';
|
||||||
import { Lens, formatLensText } from '.';
|
import { Lens, formatLensText } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import IconLens from '@/components/icons/IconLens';
|
import IconLens from '@/components/icons/IconLens';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoLens({
|
export default function PhotoLens({
|
||||||
lens,
|
lens,
|
||||||
countOnHover,
|
|
||||||
shortText,
|
shortText,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
lens: Lens
|
lens: Lens
|
||||||
countOnHover?: number
|
|
||||||
shortText?: boolean
|
shortText?: boolean
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
label={formatLensText(lens, shortText ? 'short' : 'medium')}
|
||||||
path={pathForLens(lens)}
|
path={pathForLens(lens)}
|
||||||
tooltipImagePath={pathForLensImage(lens)}
|
hoverPhotoQueryOptions={{ lens }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconLens
|
icon={<IconLens
|
||||||
size={14}
|
size={14}
|
||||||
className="translate-x-[-0.5px]"
|
className="translate-x-[-0.5px]"
|
||||||
/>}
|
/>}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,10 +14,15 @@ import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
|
|||||||
import { Photo } from '.';
|
import { Photo } from '.';
|
||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import useVisible from '@/utility/useVisible';
|
import useVisible from '@/utility/useVisible';
|
||||||
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
|
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
|
||||||
import { SortBy } from './db/sort';
|
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 = (
|
export type RevalidatePhoto = (
|
||||||
photoId: string,
|
photoId: string,
|
||||||
@ -55,22 +60,20 @@ export default function InfinitePhotoScroll({
|
|||||||
revalidatePhoto?: RevalidatePhoto
|
revalidatePhoto?: RevalidatePhoto
|
||||||
}) => ReactNode
|
}) => ReactNode
|
||||||
} & PhotoSetCategory) {
|
} & PhotoSetCategory) {
|
||||||
const { swrTimestamp, isUserSignedIn } = useAppState();
|
const { isUserSignedIn } = useAppState();
|
||||||
|
|
||||||
const key = `${swrTimestamp}-${cacheKey}`;
|
|
||||||
|
|
||||||
const keyGenerator = useCallback(
|
const keyGenerator = useCallback(
|
||||||
(size: number, prev: Photo[]) => prev && prev.length === 0
|
(size: number, prev: Photo[]) => prev && prev.length === 0
|
||||||
? null
|
? null
|
||||||
: [key, size]
|
: `${SWR_KEY_INFINITE_PHOTO_SCROLL}-${cacheKey}__${size}`
|
||||||
, [key]);
|
, [cacheKey]);
|
||||||
|
|
||||||
const fetcher = useCallback((
|
const fetcher = useCallback((
|
||||||
[_key, size]: [string, number],
|
keyWithSize: string,
|
||||||
warmOnly?: boolean,
|
warmOnly?: boolean,
|
||||||
) =>
|
) =>
|
||||||
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
|
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
|
||||||
offset: initialOffset + size * itemsPerPage,
|
offset: initialOffset + getSizeFromKey(keyWithSize) * itemsPerPage,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortWithPriority,
|
sortWithPriority,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
@ -111,7 +114,7 @@ export default function InfinitePhotoScroll({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ADMIN_DB_OPTIMIZE_ENABLED) {
|
if (ADMIN_DB_OPTIMIZE_ENABLED) {
|
||||||
fetcher(['', 0], true);
|
fetcher(`${SIZE_KEY_SEPARATOR}0`, true);
|
||||||
}
|
}
|
||||||
}, [fetcher]);
|
}, [fetcher]);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import PhotoMedium from './PhotoMedium';
|
|||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import AnimateItems from '@/components/AnimateItems';
|
import AnimateItems from '@/components/AnimateItems';
|
||||||
import { GRID_ASPECT_RATIO } from '@/app/config';
|
import { GRID_ASPECT_RATIO } from '@/app/config';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import SelectTileOverlay from '@/components/SelectTileOverlay';
|
import SelectTileOverlay from '@/components/SelectTileOverlay';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||||
@ -14,7 +14,7 @@ import { GRID_GAP_CLASSNAME } from '@/components';
|
|||||||
export default function PhotoGrid({
|
export default function PhotoGrid({
|
||||||
photos,
|
photos,
|
||||||
selectedPhoto,
|
selectedPhoto,
|
||||||
photoPriority,
|
prioritizeInitialPhotos,
|
||||||
animate = true,
|
animate = true,
|
||||||
canStart,
|
canStart,
|
||||||
animateOnFirstLoadOnly,
|
animateOnFirstLoadOnly,
|
||||||
@ -28,7 +28,7 @@ export default function PhotoGrid({
|
|||||||
}: {
|
}: {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
selectedPhoto?: Photo
|
selectedPhoto?: Photo
|
||||||
photoPriority?: boolean
|
prioritizeInitialPhotos?: boolean
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
canStart?: boolean
|
canStart?: boolean
|
||||||
animateOnFirstLoadOnly?: boolean
|
animateOnFirstLoadOnly?: boolean
|
||||||
@ -90,7 +90,7 @@ export default function PhotoGrid({
|
|||||||
photo,
|
photo,
|
||||||
...categories,
|
...categories,
|
||||||
selected: photo.id === selectedPhoto?.id,
|
selected: photo.id === selectedPhoto?.id,
|
||||||
priority: photoPriority,
|
priority: prioritizeInitialPhotos ? index < 6 : undefined,
|
||||||
onVisible: index === photos.length - 1
|
onVisible: index === photos.length - 1
|
||||||
? onLastPhotoVisible
|
? onLastPhotoVisible
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { PATH_GRID_INFERRED } from '@/app/paths';
|
|||||||
import PhotoGridSidebar from './PhotoGridSidebar';
|
import PhotoGridSidebar from './PhotoGridSidebar';
|
||||||
import PhotoGridContainer from './PhotoGridContainer';
|
import PhotoGridContainer from './PhotoGridContainer';
|
||||||
import { ComponentProps, useEffect, useRef } from 'react';
|
import { ComponentProps, useEffect, useRef } from 'react';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import useElementHeight from '@/utility/useElementHeight';
|
import useElementHeight from '@/utility/useElementHeight';
|
||||||
import MaskedScroll from '@/components/MaskedScroll';
|
import MaskedScroll from '@/components/MaskedScroll';
|
||||||
@ -42,6 +42,7 @@ export default function PhotoGridPageClient({
|
|||||||
count={photosCount}
|
count={photosCount}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortWithPriority={sortWithPriority}
|
sortWithPriority={sortWithPriority}
|
||||||
|
prioritizeInitialPhotos
|
||||||
sidebar={
|
sidebar={
|
||||||
<MaskedScroll
|
<MaskedScroll
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@ -7,10 +7,14 @@ import { photoQuantityText } from '.';
|
|||||||
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
|
||||||
import PhotoFilm from '@/film/PhotoFilm';
|
import PhotoFilm from '@/film/PhotoFilm';
|
||||||
import FavsTag from '../tag/FavsTag';
|
import FavsTag from '../tag/FavsTag';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import HiddenTag from '@/tag/HiddenTag';
|
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 { clsx } from 'clsx/lite';
|
||||||
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
import PhotoRecipe from '@/recipe/PhotoRecipe';
|
||||||
import IconCamera from '@/components/icons/IconCamera';
|
import IconCamera from '@/components/icons/IconCamera';
|
||||||
@ -124,7 +128,7 @@ export default function PhotoGridSidebar({
|
|||||||
<PhotoYear
|
<PhotoYear
|
||||||
key={year}
|
key={year}
|
||||||
year={year}
|
year={year}
|
||||||
countOnHover={count}
|
countOnHover={SHOW_CATEGORY_IMAGE_HOVERS ? count : undefined}
|
||||||
type="text-only"
|
type="text-only"
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
contrast="low"
|
contrast="low"
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
|
|||||||
import PhotoPrevNextActions from './PhotoPrevNextActions';
|
import PhotoPrevNextActions from './PhotoPrevNextActions';
|
||||||
import PhotoLink from './PhotoLink';
|
import PhotoLink from './PhotoLink';
|
||||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import { RevalidatePhoto } from './InfinitePhotoScroll';
|
|||||||
import { useCallback, useMemo, useRef } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import useVisible from '@/utility/useVisible';
|
import useVisible from '@/utility/useVisible';
|
||||||
import PhotoDate from './PhotoDate';
|
import PhotoDate from './PhotoDate';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { LuExpand } from 'react-icons/lu';
|
import { LuExpand } from 'react-icons/lu';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import Tooltip from '@/components/Tooltip';
|
import Tooltip from '@/components/Tooltip';
|
||||||
@ -80,6 +80,7 @@ export default function PhotoLarge({
|
|||||||
shouldShareRecipe,
|
shouldShareRecipe,
|
||||||
shouldShareFocalLength,
|
shouldShareFocalLength,
|
||||||
includeFavoriteInAdminMenu,
|
includeFavoriteInAdminMenu,
|
||||||
|
forceFallbackFade,
|
||||||
onVisible,
|
onVisible,
|
||||||
showAdminKeyCommands,
|
showAdminKeyCommands,
|
||||||
}: {
|
}: {
|
||||||
@ -110,6 +111,7 @@ export default function PhotoLarge({
|
|||||||
shouldShareRecipe?: boolean
|
shouldShareRecipe?: boolean
|
||||||
shouldShareFocalLength?: boolean
|
shouldShareFocalLength?: boolean
|
||||||
includeFavoriteInAdminMenu?: boolean
|
includeFavoriteInAdminMenu?: boolean
|
||||||
|
forceFallbackFade?: boolean
|
||||||
onVisible?: () => void
|
onVisible?: () => void
|
||||||
showAdminKeyCommands?: boolean
|
showAdminKeyCommands?: boolean
|
||||||
}) {
|
}) {
|
||||||
@ -232,6 +234,7 @@ export default function PhotoLarge({
|
|||||||
blurDataURL={photo.blurData}
|
blurDataURL={photo.blurData}
|
||||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
|
forceFallbackFade={forceFallbackFade}
|
||||||
/>
|
/>
|
||||||
</ZoomControls>
|
</ZoomControls>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { ReactNode, ComponentProps, RefObject } from 'react';
|
|||||||
import { Photo, titleForPhoto } from '@/photo';
|
import { Photo, titleForPhoto } from '@/photo';
|
||||||
import { PhotoSetCategory } from '@/category';
|
import { PhotoSetCategory } from '@/category';
|
||||||
import { AnimationConfig } from '../components/AnimateItems';
|
import { AnimationConfig } from '../components/AnimateItems';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { pathForPhoto } from '@/app/paths';
|
import { pathForPhoto } from '@/app/paths';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import LinkWithStatus from '@/components/LinkWithStatus';
|
import LinkWithStatus from '@/components/LinkWithStatus';
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export default function PhotoMedium({
|
|||||||
priority,
|
priority,
|
||||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||||
className,
|
className,
|
||||||
|
forceFallbackFade,
|
||||||
onVisible,
|
onVisible,
|
||||||
...categories
|
...categories
|
||||||
}: {
|
}: {
|
||||||
@ -29,6 +30,7 @@ export default function PhotoMedium({
|
|||||||
priority?: boolean
|
priority?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
forceFallbackFade?: boolean
|
||||||
onVisible?: () => void
|
onVisible?: () => void
|
||||||
} & PhotoSetCategory) {
|
} & PhotoSetCategory) {
|
||||||
const ref = useRef<HTMLAnchorElement>(null);
|
const ref = useRef<HTMLAnchorElement>(null);
|
||||||
@ -66,6 +68,7 @@ export default function PhotoMedium({
|
|||||||
classNameImage="object-cover w-full h-full"
|
classNameImage="object-cover w-full h-full"
|
||||||
alt={altTextForPhoto(photo)}
|
alt={altTextForPhoto(photo)}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
|
forceFallbackFade={forceFallbackFade}
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</LinkWithStatus>
|
</LinkWithStatus>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { PhotoSetCategory } from '../category';
|
import { PhotoSetCategory } from '../category';
|
||||||
import PhotoLink from './PhotoLink';
|
import PhotoLink from './PhotoLink';
|
||||||
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function PhotoSmall({
|
|||||||
selected,
|
selected,
|
||||||
className,
|
className,
|
||||||
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
prefetch = SHOULD_PREFETCH_ALL_LINKS,
|
||||||
|
forceFallbackFade,
|
||||||
onVisible,
|
onVisible,
|
||||||
...categories
|
...categories
|
||||||
}: {
|
}: {
|
||||||
@ -24,6 +25,7 @@ export default function PhotoSmall({
|
|||||||
selected?: boolean
|
selected?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
|
forceFallbackFade?: boolean
|
||||||
onVisible?: () => void
|
onVisible?: () => void
|
||||||
} & PhotoSetCategory) {
|
} & PhotoSetCategory) {
|
||||||
const ref = useRef<HTMLAnchorElement>(null);
|
const ref = useRef<HTMLAnchorElement>(null);
|
||||||
@ -50,6 +52,7 @@ export default function PhotoSmall({
|
|||||||
blurDataURL={photo.blurData}
|
blurDataURL={photo.blurData}
|
||||||
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
|
||||||
alt={altTextForPhoto(photo)}
|
alt={altTextForPhoto(photo)}
|
||||||
|
forceFallbackFade={forceFallbackFade}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
|
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
|
||||||
import ImageInput from '../components/ImageInput';
|
import ImageInput from '../components/ImageInput';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { RefObject, useTransition, useRef, useEffect } from 'react';
|
import { RefObject, useTransition, useRef, useEffect } from 'react';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
renamePhotoRecipeGlobally,
|
renamePhotoRecipeGlobally,
|
||||||
getPhotosNeedingRecipeTitleCount,
|
getPhotosNeedingRecipeTitleCount,
|
||||||
} from '@/photo/db/query';
|
} from '@/photo/db/query';
|
||||||
import { GetPhotosOptions, areOptionsSensitive } from './db';
|
import { PhotoQueryOptions, areOptionsSensitive } from './db';
|
||||||
import {
|
import {
|
||||||
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
|
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
|
||||||
PhotoFormData,
|
PhotoFormData,
|
||||||
@ -546,7 +546,7 @@ export const getImageBlurAction = async (url: string) =>
|
|||||||
// Public/Private actions
|
// Public/Private actions
|
||||||
|
|
||||||
export const getPhotosAction = async (
|
export const getPhotosAction = async (
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
warmOnly?: boolean,
|
warmOnly?: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (warmOnly) {
|
if (warmOnly) {
|
||||||
@ -559,7 +559,7 @@ export const getPhotosAction = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getPhotosCachedAction = async (
|
export const getPhotosCachedAction = async (
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
warmOnly?: boolean,
|
warmOnly?: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (warmOnly) {
|
if (warmOnly) {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
getUniqueRecipes,
|
getUniqueRecipes,
|
||||||
getUniqueYears,
|
getUniqueYears,
|
||||||
} from '@/photo/db/query';
|
} from '@/photo/db/query';
|
||||||
import { GetPhotosOptions } from './db';
|
import { PhotoQueryOptions } from './db';
|
||||||
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
|
||||||
import { createCameraKey } from '@/camera';
|
import { createCameraKey } from '@/camera';
|
||||||
import {
|
import {
|
||||||
@ -54,9 +54,9 @@ const KEY_YEARS = 'years';
|
|||||||
const KEY_COUNT = 'count';
|
const KEY_COUNT = 'count';
|
||||||
const KEY_DATE_RANGE = 'date-range';
|
const KEY_DATE_RANGE = 'date-range';
|
||||||
|
|
||||||
const getPhotosCacheKeyForOption = (
|
const getCacheKeyForPhotoQueryOptions = (
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
option: keyof GetPhotosOptions,
|
option: keyof PhotoQueryOptions,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
switch (option) {
|
switch (option) {
|
||||||
// Complex keys
|
// Complex keys
|
||||||
@ -81,13 +81,13 @@ const getPhotosCacheKeyForOption = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPhotosCacheKeys = (options: GetPhotosOptions = {}) => {
|
const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
|
|
||||||
Object.keys(options).forEach(key => {
|
Object.keys(options).forEach(key => {
|
||||||
const tag = getPhotosCacheKeyForOption(
|
const tag = getCacheKeyForPhotoQueryOptions(
|
||||||
options,
|
options,
|
||||||
key as keyof GetPhotosOptions,
|
key as keyof PhotoQueryOptions,
|
||||||
);
|
);
|
||||||
if (tag) { tags.push(tag); }
|
if (tag) { tags.push(tag); }
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,7 +19,7 @@ const parameterizeForDb = (field: string) =>
|
|||||||
`REPLACE(${acc}, '${from}', '${to}')`
|
`REPLACE(${acc}, '${from}', '${to}')`
|
||||||
, `LOWER(TRIM(${field}))`);
|
, `LOWER(TRIM(${field}))`);
|
||||||
|
|
||||||
export type GetPhotosOptions = {
|
export type PhotoQueryOptions = {
|
||||||
sortBy?: SortBy
|
sortBy?: SortBy
|
||||||
sortWithPriority?: boolean
|
sortWithPriority?: boolean
|
||||||
limit?: number
|
limit?: number
|
||||||
@ -35,11 +35,11 @@ export type GetPhotosOptions = {
|
|||||||
lens?: Partial<Lens>
|
lens?: Partial<Lens>
|
||||||
};
|
};
|
||||||
|
|
||||||
export const areOptionsSensitive = (options: GetPhotosOptions) =>
|
export const areOptionsSensitive = (options: PhotoQueryOptions) =>
|
||||||
options.hidden === 'include' || options.hidden === 'only';
|
options.hidden === 'include' || options.hidden === 'only';
|
||||||
|
|
||||||
export const getWheresFromOptions = (
|
export const getWheresFromOptions = (
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
initialValuesIndex = 1,
|
initialValuesIndex = 1,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
@ -149,7 +149,7 @@ export const getWheresFromOptions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
export const getOrderByFromOptions = (options: PhotoQueryOptions) => {
|
||||||
const {
|
const {
|
||||||
sortBy = APP_DEFAULT_SORT_BY,
|
sortBy = APP_DEFAULT_SORT_BY,
|
||||||
sortWithPriority,
|
sortWithPriority,
|
||||||
@ -176,7 +176,7 @@ export const getOrderByFromOptions = (options: GetPhotosOptions) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getLimitAndOffsetFromOptions = (
|
export const getLimitAndOffsetFromOptions = (
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
initialValuesIndex = 1,
|
initialValuesIndex = 1,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import {
|
|||||||
AI_TEXT_GENERATION_ENABLED,
|
AI_TEXT_GENERATION_ENABLED,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import {
|
import {
|
||||||
GetPhotosOptions,
|
PhotoQueryOptions,
|
||||||
getOrderByFromOptions,
|
getOrderByFromOptions,
|
||||||
getLimitAndOffsetFromOptions,
|
getLimitAndOffsetFromOptions,
|
||||||
getWheresFromOptions,
|
getWheresFromOptions,
|
||||||
@ -80,7 +80,7 @@ const createPhotosTable = () =>
|
|||||||
const safelyQueryPhotos = async <T>(
|
const safelyQueryPhotos = async <T>(
|
||||||
callback: () => Promise<T>,
|
callback: () => Promise<T>,
|
||||||
queryLabel: string,
|
queryLabel: string,
|
||||||
queryOptions?: GetPhotosOptions,
|
queryOptions?: PhotoQueryOptions,
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
let result: T;
|
let result: T;
|
||||||
|
|
||||||
@ -489,7 +489,7 @@ export const getUniqueFocalLengths = async () =>
|
|||||||
})))
|
})))
|
||||||
, 'getUniqueFocalLengths');
|
, 'getUniqueFocalLengths');
|
||||||
|
|
||||||
export const getPhotos = async (options: GetPhotosOptions = {}) =>
|
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
|
||||||
safelyQueryPhotos(async () => {
|
safelyQueryPhotos(async () => {
|
||||||
const sql = ['SELECT * FROM photos'];
|
const sql = ['SELECT * FROM photos'];
|
||||||
const values = [] as (string | number)[];
|
const values = [] as (string | number)[];
|
||||||
@ -526,7 +526,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) =>
|
|||||||
|
|
||||||
export const getPhotosNearId = async (
|
export const getPhotosNearId = async (
|
||||||
photoId: string,
|
photoId: string,
|
||||||
options: GetPhotosOptions,
|
options: PhotoQueryOptions,
|
||||||
) =>
|
) =>
|
||||||
safelyQueryPhotos(async () => {
|
safelyQueryPhotos(async () => {
|
||||||
const { limit } = options;
|
const { limit } = options;
|
||||||
@ -565,7 +565,7 @@ export const getPhotosNearId = async (
|
|||||||
});
|
});
|
||||||
}, `getPhotosNearId: ${photoId}`);
|
}, `getPhotosNearId: ${photoId}`);
|
||||||
|
|
||||||
export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
|
export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
|
||||||
safelyQueryPhotos(async () => {
|
safelyQueryPhotos(async () => {
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
let sql = 'SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos';
|
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 AiButton from '../ai/AiButton';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import usePreventNavigation from '@/utility/usePreventNavigation';
|
import usePreventNavigation from '@/utility/usePreventNavigation';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
||||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
||||||
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
||||||
@ -327,6 +327,7 @@ export default function PhotoForm({
|
|||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
setFormActionErrorMessage('');
|
setFormActionErrorMessage('');
|
||||||
(document.activeElement as HTMLElement)?.blur?.();
|
(document.activeElement as HTMLElement)?.blur?.();
|
||||||
|
invalidateSwr?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
@ -473,7 +474,6 @@ export default function PhotoForm({
|
|||||||
icon={type === 'create' && <IconAddUpload />}
|
icon={type === 'create' && <IconAddUpload />}
|
||||||
disabled={!canFormBeSubmitted}
|
disabled={!canFormBeSubmitted}
|
||||||
onFormStatusChange={onFormStatusChange}
|
onFormStatusChange={onFormStatusChange}
|
||||||
onFormSubmit={invalidateSwr}
|
|
||||||
primary
|
primary
|
||||||
>
|
>
|
||||||
{type === 'create' ? 'Add' : 'Update'}
|
{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
|
import EntityLink, { EntityLinkExternalProps } from
|
||||||
'@/components/primitives/EntityLink';
|
'@/components/entity/EntityLink';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
import IconRecents from '@/components/icons/IconRecents';
|
import IconRecents from '@/components/icons/IconRecents';
|
||||||
|
|
||||||
export default function PhotoRecents({
|
export default function PhotoRecents(props: EntityLinkExternalProps) {
|
||||||
countOnHover,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -18,12 +12,9 @@ export default function PhotoRecents({
|
|||||||
{...props}
|
{...props}
|
||||||
label={appText.category.recentPlural}
|
label={appText.category.recentPlural}
|
||||||
path={PREFIX_RECENTS}
|
path={PREFIX_RECENTS}
|
||||||
tooltipImagePath={pathForRecentsImage()}
|
hoverPhotoQueryOptions={{ recent: true }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconRecents size={16} />}
|
icon={<IconRecents size={16} />}
|
||||||
iconBadge={<IconRecents size={10} solid />}
|
iconBadgeStart={<IconRecents size={10} solid />}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -24,7 +24,7 @@ export default function RecentsHeader({
|
|||||||
return (
|
return (
|
||||||
<PhotoHeader
|
<PhotoHeader
|
||||||
recent={true}
|
recent={true}
|
||||||
entity={<PhotoRecents showTooltip={false} />}
|
entity={<PhotoRecents showHover={false} />}
|
||||||
entityDescription={descriptionForPhotoSet(
|
entityDescription={descriptionForPhotoSet(
|
||||||
photos,
|
photos,
|
||||||
appText,
|
appText,
|
||||||
|
|||||||
@ -1,31 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
|
import { pathForRecipe } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import { formatRecipe } from '.';
|
import { formatRecipe } from '.';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import IconRecipe from '@/components/icons/IconRecipe';
|
import IconRecipe from '@/components/icons/IconRecipe';
|
||||||
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoRecipe({
|
export default function PhotoRecipe({
|
||||||
ref,
|
ref,
|
||||||
recipe,
|
recipe,
|
||||||
countOnHover,
|
|
||||||
toggleRecipeOverlay,
|
toggleRecipeOverlay,
|
||||||
isShowingRecipeOverlay,
|
isShowingRecipeOverlay,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
recipe: string
|
recipe: string
|
||||||
countOnHover?: number
|
|
||||||
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
|
||||||
& EntityLinkExternalProps) {
|
& EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
@ -33,13 +27,11 @@ export default function PhotoRecipe({
|
|||||||
title="Recipe"
|
title="Recipe"
|
||||||
label={formatRecipe(recipe)}
|
label={formatRecipe(recipe)}
|
||||||
path={pathForRecipe(recipe)}
|
path={pathForRecipe(recipe)}
|
||||||
tooltipImagePath={pathForRecipeImage(recipe)}
|
hoverPhotoQueryOptions={{ recipe }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconRecipe
|
icon={<IconRecipe
|
||||||
size={16}
|
size={16}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
props.badged && 'translate-x-[-1px] translate-y-[0.5px]',
|
props.badged && 'translate-x-[-1px] translate-y-[-1px]',
|
||||||
)}
|
)}
|
||||||
/>}
|
/>}
|
||||||
action={toggleRecipeOverlay &&
|
action={toggleRecipeOverlay &&
|
||||||
@ -47,7 +39,6 @@ export default function PhotoRecipe({
|
|||||||
toggleRecipeOverlay,
|
toggleRecipeOverlay,
|
||||||
isShowingRecipeOverlay,
|
isShowingRecipeOverlay,
|
||||||
}} />}
|
}} />}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { Photo, PhotoDateRange } from '@/photo';
|
import { Photo, PhotoDateRange } from '@/photo';
|
||||||
import PhotoHeader from '@/photo/PhotoHeader';
|
import PhotoHeader from '@/photo/PhotoHeader';
|
||||||
import PhotoRecipe from './PhotoRecipe';
|
import PhotoRecipe from './PhotoRecipe';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
|
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
|
||||||
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
@ -35,7 +35,7 @@ export default function RecipeHeader({
|
|||||||
entity={<PhotoRecipe
|
entity={<PhotoRecipe
|
||||||
recipe={recipe}
|
recipe={recipe}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
isShowingRecipeOverlay={Boolean(recipeModalProps)}
|
||||||
toggleRecipeOverlay={recipeProps
|
toggleRecipeOverlay={recipeProps
|
||||||
? () => setRecipeModalProps?.(recipeProps)
|
? () => setRecipeModalProps?.(recipeProps)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
|
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
|
||||||
|
|
||||||
export default function ShareModals() {
|
export default function ShareModals() {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { TbPhotoShare } from 'react-icons/tb';
|
import { TbPhotoShare } from 'react-icons/tb';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { getSharePathFromShareModalProps, ShareModalProps } from '.';
|
import { getSharePathFromShareModalProps, ShareModalProps } from '.';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { toastSuccess } from '@/toast';
|
|||||||
import { PiXLogo } from 'react-icons/pi';
|
import { PiXLogo } from 'react-icons/pi';
|
||||||
import { SHOW_SOCIAL } from '@/app/config';
|
import { SHOW_SOCIAL } from '@/app/config';
|
||||||
import { generateXPostText } from '@/utility/social';
|
import { generateXPostText } from '@/utility/social';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import useOnPathChange from '@/utility/useOnPathChange';
|
import useOnPathChange from '@/utility/useOnPathChange';
|
||||||
import { IoArrowUp } from 'react-icons/io5';
|
import { IoArrowUp } from 'react-icons/io5';
|
||||||
import MaskedScroll from '@/components/MaskedScroll';
|
import MaskedScroll from '@/components/MaskedScroll';
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import TagShareModal from '@/tag/TagShareModal';
|
|||||||
import CameraShareModal from '@/camera/CameraShareModal';
|
import CameraShareModal from '@/camera/CameraShareModal';
|
||||||
import FilmShareModal from '@/film/FilmShareModal';
|
import FilmShareModal from '@/film/FilmShareModal';
|
||||||
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
import RecipeShareModal from '@/recipe/RecipeShareModal';
|
||||||
import LensShareModal from '@/lens/LensShareModal';
|
import LensShareModal from '@/lens/LensShareModal';
|
||||||
import YearShareModal from '@/years/YearShareModal';
|
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';
|
'use client';
|
||||||
|
|
||||||
import { TAG_FAVS } from '.';
|
import { TAG_FAVS } from '.';
|
||||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
import { pathForTag } from '@/app/paths';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import IconFavs from '@/components/icons/IconFavs';
|
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 (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
|
{...props}
|
||||||
label={TAG_FAVS}
|
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)}
|
path={pathForTag(TAG_FAVS)}
|
||||||
tooltipImagePath={pathForTagImage(TAG_FAVS)}
|
hoverPhotoQueryOptions={{ tag: TAG_FAVS }}
|
||||||
tooltipCaption={countOnHover &&
|
icon={<IconFavs
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={!badged &&
|
|
||||||
<IconFavs
|
|
||||||
size={13}
|
size={13}
|
||||||
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
className="translate-x-[-0.5px] translate-y-[-0.5px]"
|
||||||
highlight
|
highlight
|
||||||
/>}
|
/>}
|
||||||
type={type}
|
iconBadgeEnd={<IconFavs
|
||||||
className={className}
|
size={10}
|
||||||
hoverEntity={countOnHover}
|
className="translate-y-[-0.5px]"
|
||||||
badged={badged}
|
highlight
|
||||||
contrast={contrast}
|
/>}
|
||||||
prefetch={prefetch}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,37 +3,19 @@ import { pathForTag } from '@/app/paths';
|
|||||||
import IconHidden from '@/components/icons/IconHidden';
|
import IconHidden from '@/components/icons/IconHidden';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
|
|
||||||
export default function HiddenTag({
|
export default function HiddenTag(props: EntityLinkExternalProps) {
|
||||||
type,
|
|
||||||
badged,
|
|
||||||
contrast,
|
|
||||||
prefetch,
|
|
||||||
countOnHover,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
|
{...props}
|
||||||
label={TAG_HIDDEN}
|
label={TAG_HIDDEN}
|
||||||
labelComplex={badged &&
|
path={pathForTag(TAG_HIDDEN)}
|
||||||
<span className="inline-flex items-center gap-1">
|
icon={<IconHidden size={16} />}
|
||||||
{TAG_HIDDEN}
|
iconBadgeEnd={<IconHidden
|
||||||
<IconHidden
|
|
||||||
size={13}
|
size={13}
|
||||||
className="translate-y-[-0.5px]"
|
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { pathForTag, pathForTagImage } from '@/app/paths';
|
import { pathForTag } from '@/app/paths';
|
||||||
import { formatTag } from '.';
|
import { formatTag } from '.';
|
||||||
import EntityLink, {
|
import EntityLink, {
|
||||||
EntityLinkExternalProps,
|
EntityLinkExternalProps,
|
||||||
} from '@/components/primitives/EntityLink';
|
} from '@/components/entity/EntityLink';
|
||||||
import IconTag from '@/components/icons/IconTag';
|
import IconTag from '@/components/icons/IconTag';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoTag({
|
export default function PhotoTag({
|
||||||
tag,
|
tag,
|
||||||
countOnHover,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
tag: string
|
tag: string
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={formatTag(tag)}
|
label={formatTag(tag)}
|
||||||
path={pathForTag(tag)}
|
path={pathForTag(tag)}
|
||||||
tooltipImagePath={pathForTagImage(tag)}
|
hoverPhotoQueryOptions={{ tag }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import PhotoTag from '@/tag/PhotoTag';
|
import PhotoTag from '@/tag/PhotoTag';
|
||||||
import { isTagFavs } from '.';
|
import { isTagFavs } from '.';
|
||||||
import FavsTag from './FavsTag';
|
import FavsTag from './FavsTag';
|
||||||
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
|
import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
export default function PhotoTags({
|
export default function PhotoTags({
|
||||||
|
|||||||
@ -28,12 +28,12 @@ export default async function TagHeader({
|
|||||||
entity={isTagFavs(tag)
|
entity={isTagFavs(tag)
|
||||||
? <FavsTag
|
? <FavsTag
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>
|
/>
|
||||||
: <PhotoTag
|
: <PhotoTag
|
||||||
tag={tag}
|
tag={tag}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityVerb={appText.category.taggedPhotos}
|
entityVerb={appText.category.taggedPhotos}
|
||||||
entityDescription={descriptionForTaggedPhotos(
|
entityDescription={descriptionForTaggedPhotos(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
const LISTENER_KEYDOWN = 'keydown';
|
const LISTENER_KEYDOWN = 'keydown';
|
||||||
|
|||||||
@ -1,33 +1,24 @@
|
|||||||
import { pathForYear, pathForYearImage } from '@/app/paths';
|
import { pathForYear } from '@/app/paths';
|
||||||
import EntityLink, { EntityLinkExternalProps } from
|
import EntityLink, { EntityLinkExternalProps } from
|
||||||
'@/components/primitives/EntityLink';
|
'@/components/entity/EntityLink';
|
||||||
import IconYear from '@/components/icons/IconYear';
|
import IconYear from '@/components/icons/IconYear';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
|
||||||
import { photoQuantityText } from '@/photo';
|
|
||||||
|
|
||||||
export default function PhotoYear({
|
export default function PhotoYear({
|
||||||
year,
|
year,
|
||||||
countOnHover,
|
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
year: string
|
year: string
|
||||||
countOnHover?: number
|
|
||||||
} & EntityLinkExternalProps) {
|
} & EntityLinkExternalProps) {
|
||||||
const appText = useAppText();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntityLink
|
<EntityLink
|
||||||
{...props}
|
{...props}
|
||||||
label={year}
|
label={year}
|
||||||
path={pathForYear(year)}
|
path={pathForYear(year)}
|
||||||
tooltipImagePath={pathForYearImage(year)}
|
hoverPhotoQueryOptions={{ year }}
|
||||||
tooltipCaption={countOnHover &&
|
|
||||||
photoQuantityText(countOnHover, appText, false)}
|
|
||||||
icon={<IconYear
|
icon={<IconYear
|
||||||
size={14}
|
size={14}
|
||||||
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
className="translate-x-[0.5px] translate-y-[-0.5px]"
|
||||||
/>}
|
/>}
|
||||||
hoverEntity={countOnHover}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -29,7 +29,7 @@ export default function YearHeader({
|
|||||||
entity={<PhotoYear
|
entity={<PhotoYear
|
||||||
year={year}
|
year={year}
|
||||||
contrast="high"
|
contrast="high"
|
||||||
showTooltip={false}
|
showHover={false}
|
||||||
/>}
|
/>}
|
||||||
entityDescription={descriptionForPhotoSet(
|
entityDescription={descriptionForPhotoSet(
|
||||||
photos,
|
photos,
|
||||||
|
|||||||
@ -42,6 +42,7 @@ html {
|
|||||||
--text-3xl--line-height: 1.5rem;
|
--text-3xl--line-height: 1.5rem;
|
||||||
|
|
||||||
--animate-fade-in: fade-in 0.5s linear both running;
|
--animate-fade-in: fade-in 0.5s linear both running;
|
||||||
|
--animate-fade-in-fast: fade-in 0.2s linear both running;
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
0% { opacity: 0; }
|
0% { opacity: 0; }
|
||||||
100% { opacity: 1; }
|
100% { opacity: 1; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user